Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 580dbfd24e3cd757499dcfdbd143deac36988aca


Parents : 58f1f5e
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-07T07:30:52-05:00

feat(docs): add comprehensive documentation for MeshChatX, including installation, architecture, messaging, audio calls, and identity management; remove outdated meshchatx.md and nomadmesh_pages.md files

Changes

55 files changed, 4778 insertions(+), 1786 deletions(-)


Diff

diff --git a/docs/en/architecture.md b/docs/en/architecture.md
new file mode 100644
index 00000000..6267173d
--- /dev/null
+++ b/docs/en/architecture.md
@@ -0,0 +1,146 @@
+# Architecture and design
+
+MeshChatX is a heavily extended fork of Reticulum MeshChat. The goals below shaped how the codebase is organized.
+
+## Design goals
+
+- Keep a local-first runtime that works on desktop, mobile, containers, and single-board computers.
+- Preserve Reticulum and LXMF semantics while improving usability and operational tooling.
+- Support multiple identities in one process without cross-identity data leakage.
+- Keep the Python backend and Vue frontend independently testable.
+- Run in constrained environments with predictable SQLite behaviour.
+
+## Process overview
+
+One Python process owns the web server, Reticulum stack, and all per-identity managers. The Vue frontend is static assets served from `meshchatx/public/` after a Vite build.
+
+```
+ReticulumMeshChat (meshchat.py)
+ |
+ +-- HTTP routes (/api/v1/*, static files)
+ +-- WebSocket (/ws, /ws/telephone/audio)
+ +-- IdentityContext (per active identity)
+ | +-- SQLite via database layer
+ | +-- LXMRouter
+ | +-- TelephoneManager (LXST)
+ | +-- Domain managers (messages, map, docs, RRC, ...)
+ +-- Shared Reticulum instance (~/.reticulum by default)
+```
+
+Optional **Electron** wraps the same backend binary and loads the UI from the local HTTPS server.
+
+## Application shell
+
+`ReticulumMeshChat` in `meshchatx/meshchat.py` is the orchestration layer. It registers routes, starts and stops identity contexts, wires crash recovery, and coordinates shared process concerns.
+
+Path helpers live in `meshchatx/src/path_utils.py`, `ssl_self_signed.py`, and `env_utils.py`. `meshchat.py` re-exports them for compatibility.
+
+## Identity-scoped context
+
+`IdentityContext` in `meshchatx/src/backend/identity_context.py` encapsulates everything tied to one cryptographic identity:
+
+- Storage under `storage/identities/<identity_hash>/`
+- Identity-local SQLite database (schema version tracked in migrations)
+- LXMF router state and propagation directories
+- Manager instances for messages, announces, docs, maps, forwarding, bots, RRC, Nomad page nodes, and more
+
+Switching identities tears down the old context and loads another. Global mutable state that could leak between identities is avoided by design.
+
+## Manager-centric domain logic
+
+Feature behaviour lives in modules under `meshchatx/src/backend/`. Examples include message handling, announce trimming, documentation, maps, page nodes, telemetry, interfaces, forwarding aliases, and RN-specific tool handlers.
+
+`meshchat.py` should stay focused on transport and lifecycle. Business rules belong in managers where they can be unit tested.
+
+## Persistence
+
+- **Engine:** SQLite with explicit SQL and migrations (no ORM).
+- **Schema:** Versioned migrations run during startup and identity setup.
+- **Backups:** Automatic and manual database backups under `database-backups/`.
+- **Recovery:** `--auto-recover`, emergency mode, and Electron crash UI can restore from backups.
+
+## HTTP API
+
+Routes are registered explicitly on the aiohttp application. Categories include:
+
+- Application status and configuration
+- Authentication and session management
+- LXMF messaging and conversations
+- Telephone and voicemail
+- Interfaces and Reticulum configuration
+- Nomad Network and page nodes
+- RRC client and server
+- Tools (ping, RNPath, RNCP, RNSH, translator, bots)
+- Documentation and maintenance
+
+The frontend uses `fetch` via `apiClient.js` with CSRF tokens on mutating requests.
+
+## WebSockets
+
+The UI connects to `/ws` for low-latency updates. Event types include new LXMF messages, identity switches, telephone state, RRC activity, Nomad download progress, RNCP transfers, and plugin events. Handlers are registered in `wsEventRegistry.js` and dispatched through `wsEventBridge.js`.
+
+Audio calls can use `/ws/telephone/audio` for browser-side codec bridging.
+
+## Security model
+
+MeshChatX defaults toward secure local operation:
+
+- HTTPS and WSS enabled by default.
+- Self-signed certificates generated per identity when custom PEM files are absent.
+- Optional HTTP basic authentication (`--auth`).
+- Encrypted session cookies via `aiohttp_session`.
+- CORS, CSP, and defensive middleware on HTTP responses.
+- Access attempt logging with lockout when auth is enabled.
+
+The project includes extensive automated tests around auth and sessions. Even so, exposing MeshChatX directly to the public internet is not recommended without additional hardening.
+
+Password reset is available with `--reset-password` or `MESHCHAT_RESET_PASSWORD=true`, which clears the stored bcrypt hash so you can set a new password in the UI.
+
+## Build and packaging
+
+One source tree produces:
+
+- Development runs via `uv run python -m meshchatx.meshchat`
+- Python wheels with bundled `public/` assets
+- Container images (Dockerfile and hardened variants)
+- Electron builds for Windows, macOS, and Linux
+- Android APK via Chaquopy
+
+Frontend build output always lands in `meshchatx/public/` so runtime behaviour matches across targets.
+
+## Reliability features
+
+- Crash recovery integration in Electron and backend startup checks
+- Database integrity verification
+- Backup, restore, and snapshot APIs
+- Explicit teardown when switching identities or shutting down forwarding resources
+- Health and status endpoints suitable for container probes
+
+## Extensibility
+
+MeshChatX supports plugins with separate frontend and backend runtimes:
+
+- **Contribution registries** under `meshchatx/src/frontend/js/registries/` for navigation, tools, commands, settings, and WebSocket events.
+- **Frontend plugins** run in dedicated Workers (`PluginHost.js`) with declarative UI slots.
+- **Backend plugins** run in wasmtime with fuel metering and capability-gated host functions.
+- **HTTP API** under `/api/v1/plugins/*` for install, enable, invoke, and assets.
+
+Practical extension paths today:
+
+- Plugin manifests with `contributes` and `permissions` blocks
+- New API routes and manager modules
+- Frontend pages wired through registries
+- New settings via `ConfigManager` and CLI or environment variables
+- Database schema changes through migrations
+
+When adding features, prefer identity-scoped state, explicit migrations, endpoint tests, and narrowly declared plugin permissions.
+
+## NomadNet and Mesh Server
+
+The Nomad browser and Mesh Server (page nodes) share a rendering pipeline for Micron, Markdown, plain text, and sanitised HTML. Authoring rules are documented in **NomadNet page formats**.
+
+## Related reading
+
+- **Getting started** for UI navigation and first steps.
+- **LXMF messaging**, **Audio calls**, and **Reticulum interfaces** for feature behaviour.
+- The **Reticulum** tab in Documentation for protocol reference.

diff --git a/docs/en/audio-calls.md b/docs/en/audio-calls.md
new file mode 100644
index 00000000..c2ce38ce
--- /dev/null
+++ b/docs/en/audio-calls.md
@@ -0,0 +1,87 @@
+# Audio calls (LXST)
+
+MeshChatX uses LXST for voice telephony over Reticulum. Telephone functionality is optional and controlled per identity in settings.
+
+## Enable telephony
+
+Turn on **telephone** in settings before using the **Call** page. MeshChatX announces your callable destination under aspect `lxst.telephony` when announcing is enabled.
+
+Peers who announce the same aspect appear as callable contacts.
+
+## Placing and receiving calls
+
+From **Call** or a contact entry you can:
+
+- **Dial** another identity by hash
+- **Answer** or **decline** inbound rings
+- **Hang up** an active session
+- **Mute** transmit or receive paths
+
+Call state changes arrive over the WebSocket (`telephone_ringing`, `telephone_call_established`, `telephone_call_ended`, and related events).
+
+## Audio path
+
+The frontend loads Codec2 assets for voice encoding (`Codec2Loader.js`). Browser and Electron builds use a Web Audio bridge at `/ws/telephone/audio`. Packaged desktop builds bundle the backend that negotiates LXST sessions.
+
+## Voicemail
+
+When you miss a call, voicemail may be offered depending on settings:
+
+- Record a custom greeting
+- Upload or generate greeting audio
+- Play back messages left for you
+
+Voicemail events surface as `new_voicemail` on the WebSocket.
+
+## Call history and recordings
+
+The **Call** area keeps history of placed, received, and missed calls. You can record calls when the feature is enabled and policy allows storage on your device.
+
+## Ringtones
+
+Upload custom ringtones and assign them per contact. Default sounds are used when no override exists.
+
+## Do not disturb and contacts-only
+
+Settings support:
+
+- **Do not disturb** to silence inbound rings
+- **Contacts-only** mode to reject calls from unknown hashes
+
+Combine these with the **Blocked** list for finer control.
+
+## Telephone contacts
+
+Import and export telephone contacts separately from LXMF conversation peers. Contacts drive caller display names and ringtone overrides.
+
+## Call setup flow
+
+```
+Caller UI: initiate call
+ |
+ v
+GET /api/v1/telephone/call/{identity_hash}
+ |
+ v
+LXST Telephone session over Reticulum
+ |
+ +--> Signalling and media via LXST
+ |
+ +--> /ws/telephone/audio (browser audio bridge)
+ |
+ v
+Callee UI: ring, answer, or decline
+```
+
+## Tips
+
+- Verify **Interfaces** and paths before troubleshooting audio quality. Packet loss on the mesh affects voice.
+- Use headphones on mobile and Quest builds to prevent echo.
+- Review microphone permissions in Electron or the Android system settings if the UI shows no input level.
+- Keep LXST and Reticulum versions aligned with MeshChatX release notes when upgrading.
+
+## See also
+
+- **LXMF messaging** for text conversations with the same peers
+- **Identities, privacy, and security** for HTTPS and local access controls
+- LXST project documentation for codec and session details

diff --git a/docs/en/getting-started.md b/docs/en/getting-started.md
new file mode 100644
index 00000000..441c8f07
--- /dev/null
+++ b/docs/en/getting-started.md
@@ -0,0 +1,97 @@
+# Getting started with MeshChatX
+
+MeshChatX is a local-first mesh communications client built on the Reticulum Network Stack. It combines direct messaging over LXMF, voice calls over LXST, NomadNet page browsing, relay chat, maps, and a large set of Reticulum utilities in one application you can run on a desktop, a headless server, or a mobile device.
+
+MeshChatX is an independent fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat). It is not affiliated with the upstream project. The website is [meshchatx.com](https://meshchatx.com). Source and releases live on [GitHub](https://github.com/Quad4-Software/MeshChatX).
+
+## What you need to know first
+
+Reticulum is the mesh networking layer. It handles identities, paths, interfaces, and encrypted transport between nodes. LXMF is the messaging protocol MeshChatX uses for conversations, attachments, and propagation. LXST is the telephony layer used for audio calls.
+
+MeshChatX does not replace Reticulum. It runs Reticulum inside a Python process, exposes a web UI, and stores your per-identity data locally in SQLite.
+
+## How the application is laid out
+
+When you open MeshChatX you work inside a single-page web interface. The sidebar lists the main areas of the app. The **Tools** page groups diagnostics and utilities. **Settings** holds per-identity configuration. **Identities** lets you create or switch between separate cryptographic identities.
+
+Typical first-day workflow:
+
+1. Install MeshChatX using a method that fits your device. See **Installation and setup**.
+2. Open the web UI. The default address is `https://127.0.0.1:8000` when HTTPS is enabled.
+3. Go to **Interfaces** and add a way to reach the mesh. A TCP client, community interface suggestion, or LoRa RNode are common starting points.
+4. Wait for paths and announces to populate. Peers appear in the announces list and in feature-specific views.
+5. Open **Messages** to start an LXMF conversation, or **Nomad Network** to browse a page node.
+
+## Runtime shape
+
+MeshChatX ships as one Python service that serves both the API and the built frontend assets.
+
+```
+Browser or Electron window
+ |
+ v
+Vue 3 frontend (hash routes such as #/messages)
+ |
+ | REST under /api/v1/* and WebSocket at /ws
+ v
+meshchatx/meshchat.py (aiohttp server)
+ |
+ +--> SQLite database (per identity)
+ +--> LXMF router and message store
+ +--> LXST telephone (when enabled)
+ +--> Reticulum stack (interfaces, paths, announces)
+```
+
+The same backend code powers Docker images, Python wheels, Linux packages, Electron desktop builds, and the Android APK. Packaging differs. Behaviour is intended to stay consistent.
+
+## Main areas of the UI
+
+| Area | Route | Purpose |
+| ------------------ | --------------------- | ----------------------------------------------------- |
+| Messages | `/messages` | LXMF direct messaging, folders, attachments |
+| Audio calls | `/call` | LXST voice calls and voicemail |
+| Contacts | `/contacts` | Telephone contacts and call-related entries |
+| Relay chat | `/relay-chat` | RRC hubs and rooms (when enabled in settings) |
+| Nomad Network | `/nomadnetwork` | Browse remote NomadNet pages and files |
+| Map | `/map` | OpenLayers map, offline tiles, telemetry |
+| Archives | `/archives` | Versioned snapshots of Nomad pages |
+| Tools | `/tools` | Ping, path tools, RNCP, bots, documentation, and more |
+| Interfaces | `/interfaces` | Add and manage Reticulum interfaces |
+| Network visualiser | `/network-visualiser` | Graph view of mesh topology |
+| Blocked | `/blocked` | Blocked destinations |
+| Settings | `/settings` | Theme, language, LXMF, telephone, security |
+| Identities | `/identities` | Create, import, or switch identities |
+| Documentation | `/documentation` | MeshChatX guides and the Reticulum manual |
+
+Relay chat appears only when `rrc_enabled` is turned on in settings.
+
+## Documentation in the app
+
+The **Documentation** page has two tabs.
+
+**MeshChatX** shows the guides in this bundle. They are markdown files synced from the `docs/` directory in the repository and rendered offline inside the app.
+
+**Reticulum** shows the upstream Reticulum manual as pre-built HTML. It is bundled at build time. You can upload a newer manual ZIP if you need a different version.
+
+Use the search bar to query both sets at once. MeshChatX guide text is currently available in English. The Reticulum manual body is English. Localized landing pages exist for several languages on the Reticulum tab.
+
+## Storage locations
+
+| Data | Typical path |
+| --------------------- | -------------------------------------------------- |
+| MeshChatX app data | `~/.reticulum-meshchatx/` on Linux and macOS |
+| Reticulum config | `~/.reticulum/` |
+| Per-identity database | `<storage>/identities/<identity_hash>/database.db` |
+| Docker volume | `meshchatx-config` mounted at `/config` |
+
+Legacy upstream data may still exist under `~/.reticulum-meshchat/`. Migration tooling can move you to the MeshChatX layout.
+
+## Where to go next
+
+- **Installation and setup** covers Docker, wheels, desktop packages, and development builds.
+- **Architecture and design** explains backend managers, identity scoping, and the API model.
+- **LXMF messaging** and **Audio calls** describe day-to-day communication features.
+- **Reticulum interfaces** explains how your node joins the mesh.
+- Platform guides under **Platform guides** cover Raspberry Pi, Android Termux, Meta Quest, and Linux sandboxing.
+
+For protocol-level detail, open the **Reticulum** tab in Documentation or visit the [Reticulum manual](https://reticulum.network/manual/) online.

diff --git a/docs/en/identity-and-security.md b/docs/en/identity-and-security.md
new file mode 100644
index 00000000..b95e3fb1
--- /dev/null
+++ b/docs/en/identity-and-security.md
@@ -0,0 +1,105 @@
+# Identities, privacy, and security
+
+MeshChatX separates cryptographic identities, network security, and optional privacy controls. This page summarises how they interact.
+
+## Identities
+
+Each identity is a Reticulum key pair with its own:
+
+- SQLite database and LXMF router directory
+- Settings in the `config` table via `ConfigManager`
+- Storage path under `storage/identities/<identity_hash>/`
+
+Create, import, or switch identities from **Identities**. Only one identity is active in the UI at a time. Switching runs a teardown path so routers and managers do not leak state.
+
+Shared resources include the Reticulum process and interface configuration in `~/.reticulum` unless you override paths.
+
+## Announces
+
+MeshChatX tracks announces for aspects such as:
+
+| Aspect | Meaning |
+| ------------------- | --------------------------------- |
+| `lxmf.delivery` | Peer accepts LXMF messages |
+| `lxst.telephony` | Peer accepts LXST calls |
+| `lxmf.propagation` | Propagation node |
+| `nomadnetwork.node` | NomadNet page server |
+| `rrc.hub` | Relay chat hub (when RRC enabled) |
+
+Announce records store signal metadata and parsed app data for display names and icons.
+
+## Web UI authentication
+
+Optional HTTP basic authentication is enabled with `--auth` or `MESHCHAT_AUTH=true`. Sessions use encrypted cookies. Mutating API requests require CSRF tokens.
+
+Access attempts are logged. Repeated failures can trigger lockout when auth is enabled.
+
+Reset a forgotten password with `--reset-password` or `MESHCHAT_RESET_PASSWORD=true`, then set a new password in the UI.
+
+## Transport security
+
+- HTTPS and WSS are on by default.
+- Self-signed certificates are generated per identity when custom PEM files are missing.
+- Pass `--ssl-cert` and `--ssl-key` for managed certificates.
+- Use `--no-https` only on trusted loopback setups.
+
+Electron loads the UI from the local HTTPS origin served by the embedded backend.
+
+## IP allowlisting
+
+`app_security_settings` can restrict which client IPs may use the web UI. Combine with auth when exposing the service beyond localhost.
+
+## Privacy mode
+
+**Privacy mode** blocks outbound HTTP from MeshChatX features that would otherwise call the public internet. Translation and similar tools respect this flag.
+
+Privacy mode does not disable Reticulum mesh traffic. It limits clearnet fetches from the app itself.
+
+## Linux sandboxing
+
+Optional Landlock sandboxing on Linux restricts filesystem access for the backend. See **Linux sandboxing** in Platform guides for Firejail and Bubblewrap examples.
+
+## Blocking and filtering
+
+Use **Blocked** for specific destination hashes. Combine with sieve filters, message blocklists, and LXMF stamp policies described in **LXMF messaging**.
+
+## Data backup
+
+Database backups land in `database-backups/`. Export snapshots from **About** or the API. Electron crash recovery can offer restore when integrity checks fail.
+
+CLI restore example:
+
+```bash
+meshchatx --restore-db /path/to/backup.zip
+```
+
+## Integrity checks
+
+Startup integrity verification runs in packaged Electron builds and can be triggered from the backend. Failed checks surface recovery options instead of silently corrupting data.
+
+## Safe deployment patterns
+
+```
+Recommended for most users
+ |
+ v
+Bind 127.0.0.1, use HTTPS, enable auth if others use the same host
+ |
+ v
+Add interfaces only for meshes you trust
+ |
+ v
+Keep backups and test restore on upgrades
+```
+
+Avoid exposing port 8000 directly to the internet without a reverse proxy, strong auth, and network-level filtering. MeshChatX is designed as a personal or small-team operator console, not a multi-tenant public website.
+
+## Multi-user hosts
+
+On shared computers, use separate OS user accounts or separate `--storage-dir` values so SQLite databases and identity files do not overlap.
+
+## See also
+
+- **Architecture and design** for session and API details
+- **Installation and setup** for CLI security flags
+- Reticulum manual cryptography chapters for identity math

diff --git a/docs/en/installation.md b/docs/en/installation.md
new file mode 100644
index 00000000..2d55425a
--- /dev/null
+++ b/docs/en/installation.md
@@ -0,0 +1,153 @@
+# Installation and setup
+
+MeshChatX can be installed in several ways. All release artifacts that ship the web UI include pre-built frontend assets. You do not need Node.js on the machine that only runs the Python wheel or Docker image.
+
+## Requirements
+
+| Component | Version |
+| --------- | -------------------------------------------------- |
+| Python | 3.11 or newer (`pyproject.toml`) |
+| Node.js | 24 or newer (development and frontend builds only) |
+| pnpm | 11.1.2 (development) |
+| UV | Used by Taskfile and CI |
+
+**Browsers for the web UI:** Safari 16.4+, Chrome 111+, Firefox 128+.
+
+## Choose an install method
+
+| Method | Frontend included | Best for |
+| ---------------- | ----------------- | ---------------------------------------- |
+| Docker image | Yes | Fast server setup on Linux |
+| Python wheel | Yes | Headless install without building the UI |
+| Linux AppImage | Yes | Portable desktop on x64 or arm64 |
+| Debian `.deb` | Yes | Debian and Ubuntu systems |
+| RPM package | Yes | Fedora, RHEL, openSUSE style systems |
+| Electron desktop | Yes | Integrated desktop with bundled backend |
+| Android APK | Yes | Phones, tablets, Meta Quest sideload |
+| From source | Built locally | Development and custom builds |
+
+Release images are published to Docker Hub (`quad4io/meshchatx`) and GHCR (`ghcr.io/quad4-software/meshchatx`).
+
+## Docker
+
+Quick start with Compose:
+
+```bash
+docker compose up -d
+```
+
+Manual run with a named volume for persistence:
+
+```bash
+docker run -d --name reticulum-meshchatx \
+ --restart unless-stopped \
+ --security-opt no-new-privileges:true \
+ -p 127.0.0.1:8000:8000 \
+ -v meshchatx-config:/config \
+ ghcr.io/quad4-software/meshchatx:latest
+```
+
+Default Compose maps `127.0.0.1:8000` on the host to port `8000` in the container. Data persists in the `meshchatx-config` volume at `/config`.
+
+To bind a host directory instead, mount it at `/config`. The container runs as UID 1000. The host directory must be writable by that user.
+
+## Python wheel
+
+1. Download `reticulum_meshchatx-*-py3-none-any.whl` from [releases](https://github.com/Quad4-Software/MeshChatX/releases).
+2. Install with pip, pipx, or uv:
+
+```bash
+pip install reticulum_meshchatx-*.whl
+```
+
+3. Start the server:
+
+```bash
+meshchatx --headless --host 127.0.0.1
+```
+
+The `meshchat` command is a compatibility alias for the same entry point.
+
+## Linux AppImage and packages
+
+**AppImage**
+
+```bash
+chmod +x ./ReticulumMeshChatX-v*-linux-*.AppImage
+./ReticulumMeshChatX-v*-linux-*.AppImage
+```
+
+**Debian package**
+
+```bash
+sudo dpkg -i reticulum-meshchatx_*_amd64.deb
+```
+
+Adjust the filename for your architecture.
+
+## From source (development)
+
+```bash
+task install
+pnpm run build-frontend
+uv run python -m meshchatx.meshchat --headless --host 127.0.0.1
+```
+
+Useful task targets include `task format`, `task lint`, `task test`, and `task build`.
+
+## First launch
+
+On first run MeshChatX creates a random Reticulum identity if you do not pass one on the command line. The identity file is stored under your configured storage directory.
+
+Open the UI at the host and port you chose. HTTPS is enabled by default with a self-signed certificate unless you pass `--no-https` or provide your own PEM files.
+
+## Command-line options
+
+Common flags and environment variables:
+
+| Flag | Environment variable | Default | Description |
+| ------------------------ | ------------------------ | -------------- | ---------------------------------- |
+| `--host` | `MESHCHAT_HOST` | `127.0.0.1` | Bind address |
+| `--port` | `MESHCHAT_PORT` | `8000` | HTTP or HTTPS port |
+| `--no-https` | `MESHCHAT_NO_HTTPS` | false | Serve plain HTTP |
+| `--ssl-cert` | `MESHCHAT_SSL_CERT` | auto | TLS certificate path |
+| `--ssl-key` | `MESHCHAT_SSL_KEY` | auto | TLS private key path |
+| `--headless` | `MESHCHAT_HEADLESS` | false | Do not open a browser |
+| `--auth` | `MESHCHAT_AUTH` | false | Require HTTP basic auth for the UI |
+| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | Application data directory |
+| `--reticulum-config-dir` | (see `--help`) | `~/.reticulum` | Reticulum configuration |
+| `--identity-file` | `MESHCHAT_IDENTITY_FILE` | none | Load identity from file |
+| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | none | Reticulum log level |
+| `--auto-recover` | `MESHCHAT_AUTO_RECOVER` | false | Attempt SQLite recovery on start |
+| `--emergency` | | false | Start without database |
+| `--disable-plugins` | | false | Disable the plugin system |
+
+CLI flags override environment variables when both are set.
+
+## Reticulum manual bundle
+
+The Reticulum HTML manual is fetched at build time. After cloning the repository, run:
+
+```bash
+pnpm run build-docs
+```
+
+This populates `meshchatx/public/reticulum-docs-bundled/current/`. Without that step the Reticulum tab may show an upload prompt until you build docs or upload a manual ZIP.
+
+## Identity bootstrap
+
+You can supply an identity at startup:
+
+- `--identity-file /path/to/identity`
+- `--identity-base64` or `--identity-base32` with the corresponding environment variables
+
+Otherwise MeshChatX generates one and saves it under `<storage>/identity`. Additional identities are created from the **Identities** page. Each identity has its own database, LXMF router, and settings while sharing one Reticulum process.
+
+## After install
+
+1. Add at least one **interface** so Reticulum can reach peers.
+2. Review **Settings** for display name, theme, language, and LXMF stamp costs.
+3. Enable **telephone** in settings if you plan to use audio calls.
+4. Open **Documentation** for MeshChatX guides and the Reticulum manual offline.
+
+Platform-specific notes live under **Platform guides** in this documentation bundle.

diff --git a/docs/en/interfaces.md b/docs/en/interfaces.md
new file mode 100644
index 00000000..edbc435f
--- /dev/null
+++ b/docs/en/interfaces.md
@@ -0,0 +1,87 @@
+# Reticulum interfaces
+
+Interfaces connect your MeshChatX node to the Reticulum mesh. Manage them from the **Interfaces** page.
+
+## What an interface does
+
+Each interface is a Reticulum transport definition. Examples include TCP over the internet, UDP discovery, LoRa through an RNode, serial KISS devices, I2P tunnels, and automatic LAN discovery.
+
+MeshChatX reads and writes interface configuration in your Reticulum config directory (default `~/.reticulum`).
+
+## Supported interface types
+
+The **Add interface** flow includes:
+
+| Type | Typical use |
+| --------------------- | --------------------------------------------- |
+| TCPClientInterface | Connect outbound to a known TCP peer |
+| TCPServerInterface | Accept inbound TCP connections |
+| BackboneInterface | High-throughput backbone link |
+| UDPInterface | UDP transport with discovery helpers |
+| RNodeInterface | LoRa via RNode (serial, BLE, or IP transport) |
+| RNodeIPInterface | RNode reached over IP |
+| SerialInterface | Direct serial devices |
+| KISSInterface | KISS TNC devices |
+| I2PInterface | I2P-based Reticulum transport |
+| AutoInterface | Automatic discovery on local networks |
+| Custom external types | Advanced setups |
+
+Community-curated suggestions come from `community_interfaces.json`, sourced from [directory.rns.recipes](https://directory.rns.recipes).
+
+## Interface discovery
+
+Discovery can automatically connect to peers on your LAN or configured networks. You can maintain allowlists and blocklists, set autoconnect behaviour, and assign a network identity for discovered peers.
+
+## Import and export
+
+Export your interface set for backup or clone it to another machine. Import validates entries before applying them.
+
+## RNode tools
+
+LoRa setups often need firmware management. **Tools → RNode Flasher** opens the bundled flasher at `/rnode-flasher/`. Configure frequency, bandwidth, spreading factor, and TX power when adding an RNode interface.
+
+## Websocket server interface
+
+MeshChatX includes a custom `WebsocketServerInterface` for WebSocket-based Reticulum transport. Use it when bridging to web-friendly gateways.
+
+## Getting onto the mesh
+
+A minimal path for a new node:
+
+```
+Install MeshChatX
+ |
+ v
+Add interface (TCP client, community suggestion, or RNode)
+ |
+ v
+Reticulum establishes transport
+ |
+ v
+Paths and announces populate in the UI
+ |
+ v
+LXMF, LXST, and Nomad features become reachable
+```
+
+1. Pick a community interface or ask your mesh operator for TCP endpoint details.
+2. Add the interface and enable it.
+3. Watch the path table (**Tools → RNPath**) if connectivity fails.
+4. Enable **auto-announce** so your services are visible.
+
+## Bundled documentation hints
+
+The Interfaces UI links into the Reticulum manual sections on interface options. Open **Documentation → Reticulum** and search for `interfaces` if you need field-by-field reference.
+
+## Tips
+
+- Run only the interfaces you need. Each open port or radio adds attack surface and power draw.
+- On Raspberry Pi and Android, prefer a single well-known TCP uplink if LoRa hardware is not attached.
+- After editing Reticulum config externally, use the reload controls or restart MeshChatX so changes apply cleanly.
+- Keep firmware on RNodes current using the flasher tool before debugging RF issues.
+
+## See also
+
+- **Installation and setup** for Reticulum config directory flags
+- **Tools and utilities** for RNPath, RNProbe, and Ping
+- Reticulum manual **Interfaces** chapter for protocol-level detail

diff --git a/docs/en/messaging.md b/docs/en/messaging.md
new file mode 100644
index 00000000..d54688de
--- /dev/null
+++ b/docs/en/messaging.md
@@ -0,0 +1,111 @@
+# LXMF messaging
+
+MeshChatX uses LXMF (LXMF Message Format) for direct and store-and-forward messaging over Reticulum. Each identity has an `LXMRouter` registered under aspect `lxmf.delivery`.
+
+## Conversations
+
+Open **Messages** to see your conversation list. Each row is a peer destination you have exchanged traffic with or selected from announces.
+
+From a conversation you can:
+
+- Send and receive text messages
+- Attach images, audio clips, and files
+- Reply with quotes and add reactions
+- Organise threads into folders and pin important chats
+- Run bulk operations on multiple conversations
+
+Incoming messages arrive over the WebSocket as `lxmf_message` events. The UI updates without a full page reload.
+
+## Attachments and rich content
+
+The composer supports:
+
+- **Images** via LXMF image fields
+- **Audio** via LXMF audio fields
+- **Files** as LXMF file attachments
+- **Stickers and GIFs** when enabled in settings
+- **User icons** stored as LXMF app data
+
+Large payloads follow LXMF sizing and stamp rules configured in settings.
+
+## Propagation nodes
+
+When a peer is not reachable directly, LXMF can store messages on propagation nodes.
+
+MeshChatX can:
+
+- Run a **local propagation node** on your identity
+- **Sync** with remote propagation nodes you trust
+- **Auto-select** a preferred node via `AutoPropagationManager`
+- **Retry** failed direct deliveries through propagation when configured
+
+Manage nodes from **Tools → Propagation nodes** or related settings entries.
+
+## Stamp costs and stranger protection
+
+LXMF uses work proofs (stamps) to limit abuse. Settings let you tune:
+
+- Outbound stamp costs for your messages
+- Inbound stamp requirements for unknown senders
+- **Stranger protection** options such as blocking strangers, attachments, or links from unknown peers
+- **Flood protection** with dynamic inbound stamp costs based on rate
+
+Raise inbound costs when you operate a public-facing node. Lower them on trusted private meshes.
+
+## Filtering and blocking
+
+- **Blocked** destinations stop traffic from specific hashes.
+- **Sieve filters** (beta) drop inbound messages by pattern.
+- **Message blocklist** (beta) complements sieve rules for known bad content.
+- **Spam reporting** helps you mark unwanted conversations.
+
+## Paper messages
+
+**Tools → Paper message** generates LXMF URIs you can share as QR codes. Another MeshChatX user can ingest the URI to receive the payload. Useful for offline handoff when no live path exists yet.
+
+## Forwarding
+
+`ForwardingManager` supports alias identities that forward messages between peers according to rules you define. Configure forwarding from **Tools → Forwarder**.
+
+## Import and export
+
+You can import and export messages and folder structures for backup or migration. Operations go through the API and respect identity boundaries.
+
+## Local retention
+
+**Local message auto-delete** removes old messages after a configured retention period. Tune this in settings if you operate on storage-constrained hardware.
+
+## Messaging flow
+
+```
+Composer in UI
+ |
+ v
+POST /api/v1/lxmf-messages/send
+ |
+ v
+LXMRouter (identity-local)
+ |
+ +--> Direct path to peer destination
+ |
+ +--> Propagation node (when direct delivery fails or policy requires it)
+ |
+ v
+Peer LXMF router
+ |
+ v
+WebSocket lxmf_message event on recipient UI
+```
+
+## Tips
+
+- Set a **display name** in settings so announces show a friendly label.
+- Enable **auto-announce** so your `lxmf.delivery` aspect stays visible on the mesh.
+- Check **Interfaces** if messages stall. No path to the peer means LXMF cannot deliver.
+- Review stamp settings before joining busy public meshes.
+
+## See also
+
+- **Reticulum interfaces** for connectivity
+- **Identities, privacy, and security** for auth and HTTPS
+- Reticulum manual section on LXMF for protocol detail

diff --git a/docs/en/nomad-network.md b/docs/en/nomad-network.md
new file mode 100644
index 00000000..0c0cd821
--- /dev/null
+++ b/docs/en/nomad-network.md
@@ -0,0 +1,77 @@
+# Nomad Network and Mesh Server
+
+Nomad Network is a distributed page and file system on top of Reticulum. MeshChatX includes a browser for remote nodes and a **Mesh Server** tool for hosting your own pages.
+
+## Nomad browser
+
+Open **Nomad Network** and enter a node destination hash. MeshChatX fetches the default entry page (usually `/page/index.mu`) over Reticulum link requests.
+
+Supported page types:
+
+| Extension | Format |
+| --------- | ------------------------------------ |
+| `.mu` | Micron markup (NomadNet default) |
+| `.md` | Markdown with GFM-oriented rendering |
+| `.txt` | Plain text with preserved whitespace |
+| `.html` | Static HTML with sanitised CSS |
+
+Follow links inside pages to browse further paths on the same node. Download files offered at `/file/*` paths.
+
+Rendering uses `NomadPageRenderer.js` with DOMPurify sanitization. Micron can use a JavaScript parser or optional Go WASM when `nomad_micron_wasm_enabled` is set.
+
+## Favourites and caching
+
+Save frequent nodes as favourites. Link caching (`nomadnet_cached_links`) speeds up repeat visits on slow links.
+
+## Archives
+
+When **page archiver** is enabled, MeshChatX stores versioned snapshots of pages you visit. Open **Archives** to browse historical copies. An optional crawler can archive automatically.
+
+Archived pages use the same renderer as the live browser based on the stored `page_path` extension.
+
+## Mesh Server (page nodes)
+
+**Tools → Mesh Server** lets you run a `nomadnetwork.node` destination locally.
+
+Typical workflow:
+
+1. Create a page node in the UI.
+2. Upload `.mu`, `.md`, `.txt`, or `.html` pages and optional files.
+3. Start the node and announce it on the mesh.
+4. Share your destination hash so others can open `/page/index.mu` on your node.
+
+API endpoints under `/api/v1/page-nodes/` manage CRUD operations, start and stop, and file listings.
+
+Pages are served at `/page/<name>` and files at `/file/<name>` on the node destination.
+
+## Browsing flow
+
+```
+User enters destination hash
+ |
+ v
+RNS link request to /page/index.mu (or chosen path)
+ |
+ v
+Remote page node responds with content
+ |
+ v
+NomadPageRenderer picks Micron, Markdown, text, or HTML pipeline
+ |
+ v
+Sanitised HTML shown in Nomad Network view
+```
+
+## Authoring pages
+
+Read **NomadNet page formats** for security rules, Markdown quirks, and API behaviour. The Mesh Server rejects disallowed extensions on upload.
+
+## Micron editor
+
+**Tools → Micron editor** helps author `.mu` pages before you upload them to your node.
+
+## See also
+
+- **NomadNet page formats** for detailed authoring reference
+- **Tools and utilities** for the full tools list
+- **Reticulum interfaces** if remote pages time out (likely a path issue)

diff --git a/docs/en/nomadmesh-pages.md b/docs/en/nomadmesh-pages.md
new file mode 100644
index 00000000..325726d9
--- /dev/null
+++ b/docs/en/nomadmesh-pages.md
@@ -0,0 +1,52 @@
+# NomadNet page formats
+
+MeshChatX serves pages from a **Mesh Server** page node and displays them in the **Nomad Network** browser. Pages are fetched with the Nomad path convention `/page/<filename>`.
+
+## Supported filenames
+
+| Extension | Role |
+| --------- | ------------------------------------------------------- |
+| `.mu` | Micron markup (NomadNet default) |
+| `.md` | Markdown with GitHub-flavored features via the renderer |
+| `.txt` | Plain text with escaped HTML and preserved whitespace |
+| `.html` | Static HTML with CSS only (see security below) |
+
+If you add a page without a recognised extension, the server stores it as `.mu`. Filenames with other extensions (for example `.exe`) are rejected when saving through the API.
+
+## Plain text (`.txt`)
+
+Content is HTML-escaped and shown with pre-wrapped whitespace. There is no Markdown parsing on `.txt` pages.
+
+## Markdown (`.md`)
+
+**Not the same engine as chat.** Conversations use the lightweight `MarkdownRenderer` in the messaging UI. Nomad `.md` pages use `marked` with GFM-oriented rules plus sanitisation. Features and edge cases can differ between the two paths. Automated tests cover both.
+
+Authoring tips:
+
+- Use ATX headings with a hash and a space before the title, for example `# Title`, `## Section`, `#### Subsection`.
+- Fenced code blocks keep indentation.
+- Off-mesh `http` and `https` links in rendered content are removed or restricted so the preview cannot drive external navigation without mesh-style URLs.
+
+## HTML (`.html`)
+
+- **JavaScript** is not executed. `script` tags and event-handler attributes are stripped.
+- **External resources** are blocked where possible. `@import` and `url(...)` pointing at `http://`, `https://`, or protocol-relative URLs are removed from CSS.
+- Embedded `<style>` blocks are kept. Rules that target `html` or `body` are rewritten to apply to the viewer root container.
+- **Links** must be mesh-style (`:` paths, 32-character hex prefixes, `/page/...`, `/file/...`, or `#` fragments) or they are removed.
+- **Images** only keep `data:image/...` inline sources.
+- The viewer uses a sans-serif font for HTML and Markdown so pages do not inherit Micron monospace chrome. Override colours and typography with your own CSS.
+
+## Mesh Server API
+
+- `POST /api/v1/page-nodes/{node_id}/pages` with `name` and `content` saves a page. Invalid extensions return HTTP 400 with a short message.
+- Listed pages only include files with allowed extensions in the `pages/` directory.
+
+## Archives
+
+Snapshots in **Archives** use the same rendering pipeline as the Nomad browser. The archived `page_path` extension selects Micron, Markdown, text, or HTML handling. Exports keep the original extension when it is `.mu`, `.md`, `.txt`, or `.html`.
+
+## See also
+
+- **Nomad Network and Mesh Server** for browsing and hosting workflows
+- **Architecture and design** for where page nodes fit in the backend
+- Default Nomad entry path remains `/page/index.mu` unless you change the URL in the browser

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_android_with_termux.md b/docs/en/platform-guides/android-termux.md
similarity index 98%
rename from meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_android_with_termux.md
rename to docs/en/platform-guides/android-termux.md
index 9048d00e..9c8c2908 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_android_with_termux.md
+++ b/docs/en/platform-guides/android-termux.md
@@ -1,4 +1,4 @@
-# MeshChatX on Android
+# Android with Termux
It's possible to run MeshChatX on Android using [Termux](https://termux.dev/). Installation is now much simpler since the wheel package includes both the server and pre-built web assets.

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx_linux_sandbox.md b/docs/en/platform-guides/linux-sandbox.md
similarity index 99%
rename from meshchatx/src/frontend/public/meshchatx-docs/meshchatx_linux_sandbox.md
rename to docs/en/platform-guides/linux-sandbox.md
index 1a7b03dc..812f691b 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx_linux_sandbox.md
+++ b/docs/en/platform-guides/linux-sandbox.md
@@ -1,4 +1,4 @@
-# MeshChatX on Linux: Firejail and Bubblewrap
+# Linux sandboxing with Firejail and Bubblewrap
This page shows how to run **`meshchatx`** under **Firejail** or **Bubblewrap** (`bwrap`) on Linux. The legacy CLI name **`meshchat`** installs the same entry point and can be substituted in these examples. Use this when you install MeshChatX natively (wheel, package, or Poetry) and want an extra layer of filesystem and process isolation compared to running the binary directly.

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_quest_with_sidequest.md b/docs/en/platform-guides/quest-sidequest.md
similarity index 91%
rename from meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_quest_with_sidequest.md
rename to docs/en/platform-guides/quest-sidequest.md
index 33517894..7d7bb7d5 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_quest_with_sidequest.md
+++ b/docs/en/platform-guides/quest-sidequest.md
@@ -1,11 +1,9 @@
-# MeshChatX on Meta Quest (Quest 2 and newer)
+# Meta Quest with SideQuest
The MeshChatX Android APK runs on Meta Quest 2, Quest 3, Quest 3S, and Quest Pro. Quest headsets run a modified Android runtime, so the same universal APK published for phones and tablets can be installed by sideloading.
MeshChatX opens as a **2D panel** inside your VR environment. It is not a native VR application. You get the full MeshChatX web UI in a floating window while you remain in your Quest home space.
-![MeshChatX running on Meta Quest 2](../screenshots/vr/meshchatx-quest2.jpeg)
-
## What you need
- A Meta Quest 2 or newer headset

diff --git a/docs/meshchatx_on_raspberry_pi.md b/docs/en/platform-guides/raspberry-pi.md
similarity index 99%
rename from docs/meshchatx_on_raspberry_pi.md
rename to docs/en/platform-guides/raspberry-pi.md
index bd118a4c..a02651b8 100644
--- a/docs/meshchatx_on_raspberry_pi.md
+++ b/docs/en/platform-guides/raspberry-pi.md
@@ -1,4 +1,4 @@
-# MeshChatX on Raspberry Pi
+# Raspberry Pi headless setup
This guide shows a simple headless setup for running MeshChatX on a Raspberry Pi 4
with a web UI you can access from another device on your network.

diff --git a/docs/en/tools.md b/docs/en/tools.md
new file mode 100644
index 00000000..bc98c672
--- /dev/null
+++ b/docs/en/tools.md
@@ -0,0 +1,105 @@
+# Tools and utilities
+
+The **Tools** page groups mesh diagnostics and helper apps. Each tool opens its own view with a back link to the grid.
+
+## Network diagnostics
+
+| Tool | Purpose |
+| ------------------ | -------------------------------------------------- |
+| Ping | Measure round-trip time to a reachable destination |
+| RNProbe | Probe whether a destination answers |
+| RNPath | Inspect the path table |
+| RNPath-trace | Trace hops toward a destination |
+| RNStatus | Read node status information |
+| Network visualiser | Graph view of topology (also in main navigation) |
+
+Use these when messages or pages fail despite interfaces showing as enabled.
+
+## File transfer and shell
+
+| Tool | Purpose |
+| ---- | ------------------------------------------ |
+| RNCP | Send or fetch files over Reticulum |
+| RNSH | Remote shell sessions with streamed output |
+
+RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
+
+## Messaging helpers
+
+| Tool | Purpose |
+| ----------------- | ----------------------------------------------- |
+| Propagation nodes | Manage LXMF propagation nodes and sync |
+| Forwarder | Configure LXMF forwarding rules between aliases |
+| Sieve filters | Pattern-based inbound message filtering (beta) |
+| Message blocklist | Block known unwanted content (beta) |
+| Paper message | Create or ingest LXMF URIs and QR workflows |
+| Bots | Run subprocess LXMF bots from templates |
+
+Bot templates include echo, note, and reminder starters. They use the bundled `lxmfy` package.
+
+## Content and publishing
+
+| Tool | Purpose |
+| ------------- | ------------------------------------- |
+| Mesh Server | Host NomadNet-compatible page nodes |
+| Micron editor | Edit `.mu` pages locally |
+| Documentation | MeshChatX guides and Reticulum manual |
+
+## Configuration editors
+
+| Tool | Purpose |
+| ----------------------- | --------------------------------------- |
+| Reticulum config editor | Edit raw Reticulum configuration |
+| Repository server | Host Python wheels for offline installs |
+
+## Hardware and translation
+
+| Tool | Purpose |
+| ------------- | ---------------------------------------------------- |
+| RNode flasher | Flash or update RNode firmware |
+| Translator | Translate text via Argos Translate or LibreTranslate |
+
+Translator calls respect **privacy mode**. When privacy mode blocks outbound HTTP, external translation endpoints are not contacted.
+
+## Debugging
+
+| Tool | Purpose |
+| ---------- | ----------------------------- |
+| Debug logs | View backend debug log stream |
+
+## Coming soon
+
+The registry marks **RNS Tunnel** and **RNS FileSync** as coming soon. They do not have routes in the current release.
+
+## Relay chat server
+
+When `rrc_enabled` is on, you can run a local RRC hub from relay chat server settings. Hubs announce aspect `rrc.hub`. Client UI lives under **Relay chat** in the main navigation.
+
+## Plugins
+
+Installed plugins can add rows to **Tools** and **Navigation** through contribution manifests. Example bundled plugin: **Mesh Observatory** (`com.meshchatx.mesh-observatory`) for live announce feeds and path tables.
+
+Disable plugins at startup with `--disable-plugins` if you need a minimal surface.
+
+## Command palette
+
+Press the command palette shortcut (configured in settings) to jump to tools and pages without returning to the grid.
+
+## Choosing a tool
+
+```
+Symptom Tool to try first
+-------------------------------- -----------------
+No peers visible Interfaces, then RNPath
+Message stuck sending RNPath, Propagation nodes
+Cannot reach Nomad page Ping, RNProbe
+Need to push a file RNCP
+Remote administration RNSH (with care)
+Want offline Python packages Repository server
+```
+
+## See also
+
+- **Reticulum interfaces** for transport setup
+- **LXMF messaging** and **Nomad Network** for feature-specific workflows
+- **Documentation** for offline manuals

diff --git a/docs/manifest.json b/docs/manifest.json
new file mode 100644
index 00000000..feb5faf0
--- /dev/null
+++ b/docs/manifest.json
@@ -0,0 +1,105 @@
+{
+ "version": 1,
+ "default_language": "en",
+ "languages": [{ "code": "en", "name": "English" }],
+ "sections": [
+ {
+ "id": "overview",
+ "order": 1,
+ "title": { "en": "Overview" },
+ "items": [
+ {
+ "path": "en/getting-started.md",
+ "lang": "en",
+ "title": { "en": "Getting started" }
+ },
+ {
+ "path": "en/installation.md",
+ "lang": "en",
+ "title": { "en": "Installation and setup" }
+ },
+ {
+ "path": "en/architecture.md",
+ "lang": "en",
+ "title": { "en": "Architecture and design" }
+ }
+ ]
+ },
+ {
+ "id": "features",
+ "order": 2,
+ "title": { "en": "Features" },
+ "items": [
+ {
+ "path": "en/messaging.md",
+ "lang": "en",
+ "title": { "en": "LXMF messaging" }
+ },
+ {
+ "path": "en/audio-calls.md",
+ "lang": "en",
+ "title": { "en": "Audio calls (LXST)" }
+ },
+ {
+ "path": "en/nomad-network.md",
+ "lang": "en",
+ "title": { "en": "Nomad Network and Mesh Server" }
+ },
+ {
+ "path": "en/interfaces.md",
+ "lang": "en",
+ "title": { "en": "Reticulum interfaces" }
+ },
+ {
+ "path": "en/tools.md",
+ "lang": "en",
+ "title": { "en": "Tools and utilities" }
+ },
+ {
+ "path": "en/identity-and-security.md",
+ "lang": "en",
+ "title": { "en": "Identities, privacy, and security" }
+ }
+ ]
+ },
+ {
+ "id": "authoring",
+ "order": 3,
+ "title": { "en": "Authoring" },
+ "items": [
+ {
+ "path": "en/nomadmesh-pages.md",
+ "lang": "en",
+ "title": { "en": "NomadNet page formats" }
+ }
+ ]
+ },
+ {
+ "id": "platforms",
+ "order": 4,
+ "title": { "en": "Platform guides" },
+ "items": [
+ {
+ "path": "en/platform-guides/raspberry-pi.md",
+ "lang": "en",
+ "title": { "en": "Raspberry Pi" }
+ },
+ {
+ "path": "en/platform-guides/android-termux.md",
+ "lang": "en",
+ "title": { "en": "Android (Termux)" }
+ },
+ {
+ "path": "en/platform-guides/quest-sidequest.md",
+ "lang": "en",
+ "title": { "en": "Meta Quest (SideQuest)" }
+ },
+ {
+ "path": "en/platform-guides/linux-sandbox.md",
+ "lang": "en",
+ "title": { "en": "Linux sandboxing" }
+ }
+ ]
+ }
+ ]
+}

diff --git a/docs/meshchatx.md b/docs/meshchatx.md
deleted file mode 100644
index 0628cdf2..00000000
--- a/docs/meshchatx.md
+++ /dev/null
@@ -1,174 +0,0 @@
-# MeshChatX Architecture and Design
-
-MeshChatX is a very heavily customized fork of Reticulum-Meshchat, it is vastly different under the hood.
-
-## Goals and Constraints
-
-- Keep a local-first runtime model that works on desktop and headless systems.
-- Preserve Reticulum and LXMF semantics while improving UX and operational tooling.
-- Support multi-identity usage in one runtime without cross-identity data leakage.
-- Keep the backend and frontend independently testable.
-- Run in constrained environments (single board devices, containers, AppImage/desktop).
-
-## System Overview
-
-At a high level, MeshChatX is a single-process Python service that:
-
-- initializes identity-specific context and persistent state,
-- exposes HTTP API and WebSocket endpoints for the frontend,
-- serves the built frontend assets from a local public directory,
-- manages LXMF/Reticulum interactions and higher-level features.
-
-The frontend is a SPA built with Vite and mounted in the same runtime context as the API.
-
-## Runtime Topology
-
-### Backend Runtime
-
-- Main entrypoint: `meshchatx/meshchat.py` (orchestration). Shared helpers live in `meshchatx/src/path_utils.py`, `meshchatx/src/ssl_self_signed.py`, and `meshchatx/src/env_utils.py`; `meshchat.py` re-exports them for compatibility.
-- Web stack: `aiohttp` + `aiohttp_session`
-- Realtime channel: WebSocket endpoints for UI updates and control flows
-- Transport/security: HTTPS by default, optional HTTP, optional custom cert paths
-
-### Frontend Runtime
-
-- Source tree: `meshchatx/src/frontend`
-- Build output: `meshchatx/public`
-- Served by backend static routing
-- Uses API + WebSocket for state hydration and live updates
-
-### Optional Desktop Runtime
-
-- Electron packaging/build scripts at repository root
-- Backend binaries/resources are bundled for packaged desktop artifacts
-
-## Core Backend Design
-
-### 1) Application Shell
-
-`ReticulumMeshChat` in `meshchatx/meshchat.py` is the orchestration layer. It owns:
-
-- server lifecycle,
-- route registration,
-- identity context switching and teardown,
-- shared process-level concerns (logging, crash recovery wiring, health checks).
-
-It intentionally centralizes operational control so runtime state changes happen in a predictable order.
-
-### 2) Identity-Scoped Context Model
-
-`IdentityContext` in `meshchatx/src/backend/identity_context.py` encapsulates state for one identity:
-
-- storage path rooted at `storage/identities/<identity_hash>/`,
-- identity-local SQLite DB,
-- identity-local LXMF router state,
-- manager instances (messages, announces, docs, map, forwarding, tools, and more).
-
-This boundary prevents accidental cross-identity writes and keeps teardown deterministic.
-
-### 3) Manager-Centric Domain Logic
-
-Feature logic is delegated to dedicated backend modules under `meshchatx/src/backend`:
-
-- message handling and routing,
-- announce management and trimming/limits,
-- docs, maps, page nodes, telemetry, interfaces,
-- forwarding aliases and propagation synchronization,
-- utility handlers for RN-specific tooling.
-
-The design intent is to keep transport/runtime orchestration in `meshchat.py` and business/domain behavior in dedicated managers. Optional **RNS log level** is configured with **`--rns-log-level`** or **`MESHCHAT_RNS_LOG_LEVEL`** (CLI overrides env when both are set).
-
-### 4) Persistence Layer
-
-- Storage engine: SQLite
-- Access style: explicit SQL-oriented data access layer (no heavyweight ORM)
-- Schema migration and integrity checks are integrated into startup and context setup.
-
-The project favors predictable SQL behavior and explicit migration control, which helps with compatibility and debugging on diverse platforms.
-
-## API and Realtime Design
-
-### HTTP API
-
-- Implemented as explicit `aiohttp` routes in `meshchat.py`
-- Includes app status, auth, messaging, interfaces, docs/tools, and maintenance endpoints
-- Static assets are served from the frontend build output directory
-
-### WebSockets
-
-- Used for low-latency frontend state updates
-- Keeps UI responsive for message state transitions and live network events
-
-### Session/Auth Flow
-
-- Cookie sessions via encrypted storage
-- Auth and access-attempt tracking integrated with IP/User-Agent aware controls
-- Debug endpoints provide visibility into logs and access-attempt records
-- Password reset via `--reset-password` (or `MESHCHAT_RESET_PASSWORD=true`) clears the stored bcrypt hash so a new password can be set through the web UI
-
-This is also very well tested, but I still would not recommend exposing MeshChatX to the internet.
-
-## Security Model
-
-MeshChatX defaults toward secure local operation:
-
-- HTTPS/WSS enabled by default.
-- Self-signed cert generation if identity-local cert files are absent.
-- Optional custom cert/key pair when deployment needs managed TLS material.
-- CORS and CSP
-- Session encryption and defensive middleware.
-- Access attempt persistence plus lockout/rate limiting strategy (when auth enabled).
-
-Since its HTTPS/WSS other local apps cannot sniff the traffic as easily.
-
-## Build and Packaging Strategy
-
-MeshChatX supports multiple deployment forms from one source tree:
-
-- source/development execution,
-- Python package and wheel distribution,
-- container images,
-- Electron desktop builds for major platforms.
-
-The design uses a shared backend codebase and frontend build artifacts so feature behavior remains consistent across packaging targets.
-
-## Operations and Reliability
-
-Reliability features include:
-
-- crash recovery integration,
-- startup integrity/database health checks,
-- backup/restore and snapshot support,
-- explicit teardown flows for multi-context and forwarding resources,
-- status endpoint for orchestration and container probes.
-
-## NomadNet pages and Mesh Server
-
-The built-in **NomadNet** browser and **Mesh Server** (page nodes) support Micron (`.mu`), Markdown (`.md`), plain text (`.txt`), and sanitised static HTML (`.html`). Pages are registered under `/page/<name>` on each node’s destination.
-
-Authoring rules, security constraints for HTML/CSS, and API behaviour are documented in **`nomadmesh_pages.md`** in the same docs bundle (also available under **Documentation** in the app when MeshChatX docs are populated).
-
-## Extensibility Points
-
-MeshChatX supports a capability-based plugin system with separate frontend and backend runtimes:
-
-- **Contribution registries** under `meshchatx/src/frontend/js/registries/` for sidebar navigation, tools, command palette actions, settings sections, and typed WebSocket events.
-- **Frontend plugins** run in dedicated Workers (`meshchatx/src/frontend/js/plugins/PluginHost.js`) with declarative UI slots rendered by `PluginSlotRenderer.vue`.
-- **Backend plugins** run in wasmtime with fuel metering and capability-gated host functions (`meshchatx/src/backend/plugin_manager.py`).
-- **Generic plugin API** under `/api/v1/plugins/*` for install, enable/disable, invoke, and asset serving.
-
-The most practical extension points today are:
-
-- plugin manifests in `plugin.json` with `contributes` and `permissions` blocks,
-- new API routes in backend routing sections,
-- new manager modules under `meshchatx/src/backend`,
-- frontend page/component additions wired through contribution registries,
-- new config surface through CLI flags + environment variables,
-- schema extension through the existing migration/versioning approach.
-
-When adding features, prefer:
-
-- identity-scoped state over global mutable state,
-- explicit migration/version changes for DB schema updates,
-- endpoint-level tests plus focused manager unit tests,
-- plugin permissions that are declared in manifests and enforced by the host.

diff --git a/docs/nomadmesh_pages.md b/docs/nomadmesh_pages.md
deleted file mode 100644
index bde3cfc4..00000000
--- a/docs/nomadmesh_pages.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# NomadNet Network browser and Mesh Server pages
-
-MeshChatX can serve pages from a **Mesh Server** (local Reticulum page node) found in the tools section and display them in the **NomadNet** browser. Pages are fetched over the usual Nomad path convention: `/page/<filename>`.
-
-## Supported filenames
-
-| Extension | Role |
-| --------- | ------------------------------------------------------------ |
-| `.mu` | **Micron** markup (NomadNet default). |
-| `.md` | **Markdown** (GitHub-flavored features via the renderer). |
-| `.txt` | **Plain text** (shown escaped, monospace-friendly wrapping). |
-| `.html` | **Static HTML** with **CSS only** (see security below). |
-
-If you add a page without a recognised extension, the server stores it as **`.mu`**. Filenames with other extensions (for example `.exe`) are rejected when saving through the API.
-
-## Plain text (`.txt`)
-
-Content is **HTML-escaped** and shown with **pre-wrapped** whitespace. There is no Markdown parsing on `.txt` pages.
-
-## Markdown (`.md`)
-
-- **Not the same engine as chat:** Conversations use the lightweight **`MarkdownRenderer`** (HTML-escaped first, then patterns for headers, bold, code, links). Nomad **`.md`** pages use **`marked`** (GFM-oriented) plus sanitisation, so features and edge cases can differ. Automated tests cover both paths.
-- Use **ATX headings** with a hash and a **space** before the title, for example `# Title`, `## Section`, `#### Subsection`. CommonMark requires that space; MeshChatX may normalise some common shorthand forms, but relying on the standard form is safest.
-- Line breaks and spacing: the viewer preserves wrapping behaviour suitable for technical text; fenced code blocks keep indentation.
-- Links are **sanitised**: off-mesh `http`/`https` links in rendered content are removed or restricted so the preview cannot drive external navigation without mesh-style URLs.
-
-## HTML (`.html`)
-
-- **JavaScript** is not executed: `script` tags and event-handler attributes are stripped.
-- **External resources** are blocked where possible: `@import` and `url(...)` pointing at `http://`, `https://`, or protocol-relative URLs are removed from CSS. Embedded `<style>` blocks are kept; rules that target `html` or `body` are **rewritten** to apply to the viewer’s root container so your layout still applies.
-- **Links**: `href` values that are not mesh-style (`:` paths, 32-character hex prefixes, `/page/...`, `/file/...`, or `#` fragments) are removed. Images only keep `data:image/...` sources for inline images.
-- The viewer uses a **sans-serif** font for HTML and Markdown so pages do not inherit the monospace Micron chrome. You can override colours and typography with your own CSS.
-
-## Mesh Server API
-
-- `POST /api/v1/page-nodes/{node_id}/pages` with `name` and `content` saves a page; invalid extensions return **400** with a short message.
-- Listed pages only include files with allowed extensions in the `pages/` directory.
-
-## Archives
-
-Snapshots in **Archives** use the same rendering pipeline as the Nomad browser (Micron, Markdown, text, sanitised HTML) using the archived `page_path` to pick the format. Exports keep the original extension when it is `.mu`, `.md`, `.txt`, or `.html`.
-
-## See also
-
-- Architecture overview: `meshchatx.md` in this docs bundle.
-- Default Nomad entry path remains `/page/index.mu` unless you change the URL in the browser.

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 2170e59a..6bf739c0 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -3793,7 +3793,14 @@ class ReticulumMeshChat:
if not path.startswith("/api/"):
if (
path == "/"
- or path.startswith(("/assets/", "/favicons/"))
+ or path.startswith(
+ (
+ "/assets/",
+ "/favicons/",
+ "/reticulum-docs/",
+ "/meshchatx-docs/",
+ ),
+ )
or path in ("/manifest.json", "/service-worker.js")
or path.endswith(
(
@@ -3834,11 +3841,15 @@ class ReticulumMeshChat:
# check if path is public
is_public = any(path.startswith(public) for public in public_paths)
+ if path.startswith(("/reticulum-docs/", "/meshchatx-docs/")):
+ is_public = True
# check if requesting setup page (index.html will show setup if needed)
if (
path == "/"
- or path.startswith(("/assets/", "/favicons/"))
+ or path.startswith(
+ ("/assets/", "/favicons/", "/reticulum-docs/", "/meshchatx-docs/"),
+ )
or path.endswith(
(
".js",
@@ -6745,7 +6756,8 @@ class ReticulumMeshChat:
# get meshchatx docs list
@routes.get("/api/v1/meshchatx-docs/list")
async def meshchatx_docs_list(request):
- return web.json_response(self.docs_manager.get_meshchatx_docs_list())
+ lang = request.query.get("lang", "en")
+ return web.json_response(self.docs_manager.get_meshchatx_docs_list(lang))
# get meshchatx doc content
@routes.get("/api/v1/meshchatx-docs/content")
@@ -6753,6 +6765,8 @@ class ReticulumMeshChat:
path = request.query.get("path")
if not path:
return web.json_response({"error": "No path provided"}, status=400)
+ if not self.docs_manager._is_safe_doc_path(path):
+ return web.json_response({"error": "Invalid path"}, status=400)
content = self.docs_manager.get_doc_content(path)
if not content:
@@ -14621,37 +14635,42 @@ class ReticulumMeshChat:
# Serve Reticulum docs from user-uploaded storage with a fallback to the
# bundled offline copy shipped under <public>/reticulum-docs-bundled/current.
# No remote network fallback exists; users supply replacements via upload.
- if self.current_context and hasattr(self.current_context, "docs_manager"):
- dm = self.current_context.docs_manager
-
- async def reticulum_docs_handler(request):
- path = request.match_info.get("filename", "manual/index.html")
- if not path:
- path = "manual/index.html"
- if path.endswith("/"):
- path += "index.html"
+ async def reticulum_docs_handler(request):
+ dm = self.docs_manager
+ if dm is None:
+ return web.json_response(
+ {"error": "Documentation unavailable"},
+ status=503,
+ )
+ path = request.match_info.get("filename", "manual/index.html")
+ if not path:
+ path = "manual/index.html"
+ if path.endswith("/"):
+ path += "index.html"
- resolved = dm.find_docs_file(path)
- if resolved is None:
- return web.json_response(
- {"error": "Documentation not found"},
- status=404,
- )
- return web.FileResponse(resolved)
+ resolved = dm.find_docs_file(path)
+ if resolved is None:
+ return web.json_response(
+ {"error": "Documentation not found"},
+ status=404,
+ )
+ return web.FileResponse(resolved)
- app.router.add_get("/reticulum-docs/{filename:.*}", reticulum_docs_handler)
+ app.router.add_get("/reticulum-docs/{filename:.*}", reticulum_docs_handler)
- if (
- dm.meshchatx_docs_dir
- and os.path.exists(dm.meshchatx_docs_dir)
- and not dm.meshchatx_docs_dir.startswith(public_dir)
- ):
- app.router.add_static(
- "/meshchatx-docs/",
- dm.meshchatx_docs_dir,
- name="meshchatx_docs_storage",
- follow_symlinks=True,
- )
+ dm = self.docs_manager
+ if (
+ dm
+ and dm.meshchatx_docs_dir
+ and os.path.exists(dm.meshchatx_docs_dir)
+ and not dm.meshchatx_docs_dir.startswith(public_dir)
+ ):
+ app.router.add_static(
+ "/meshchatx-docs/",
+ dm.meshchatx_docs_dir,
+ name="meshchatx_docs_storage",
+ follow_symlinks=True,
+ )
if os.path.exists(public_dir):
app.router.add_static("/", public_dir, name="static", follow_symlinks=True)

diff --git a/meshchatx/src/backend/docs_manager.py b/meshchatx/src/backend/docs_manager.py
index 52963298..65436816 100644
--- a/meshchatx/src/backend/docs_manager.py
+++ b/meshchatx/src/backend/docs_manager.py
@@ -2,6 +2,7 @@
import html
import io
+import json
import logging
import os
import re
@@ -11,6 +12,8 @@ import zipfile
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
BUNDLED_DOCS_SUBDIR = os.path.join("reticulum-docs-bundled", "current")
+MANIFEST_FILENAME = "manifest.json"
+DOC_FILE_SUFFIXES = (".md", ".txt")
class DocsManager:
@@ -199,101 +202,168 @@ class DocsManager:
logging.warning("MeshChatX docs source directory not found.")
return
- seen_basenames: set[str] = set()
- sourced: list[tuple[str, str]] = []
+ src_docs = candidate_dirs[0]
for base in candidate_dirs:
- try:
- names = sorted(os.listdir(base))
- except OSError:
- continue
- for file in names:
- if not file.endswith((".md", ".txt")):
- continue
- if file in seen_basenames:
- continue
- seen_basenames.add(file)
- sourced.append((file, base))
+ if os.path.isfile(os.path.join(base, MANIFEST_FILENAME)):
+ src_docs = base
+ break
- if not sourced:
- logging.warning(
- "No MeshChatX .md or .txt files found in docs search paths."
- )
+ if not os.access(self.meshchatx_docs_dir, os.W_OK):
+ logging.warning("MeshChatX docs directory is not writable.")
return
try:
- index_links: list[str] = []
- for file, src_docs in sourced:
- src_path = os.path.join(src_docs, file)
- dest_path = os.path.join(self.meshchatx_docs_dir, file)
-
- if os.path.abspath(src_path) != os.path.abspath(
- dest_path,
- ) and os.access(self.meshchatx_docs_dir, os.W_OK):
- shutil.copy2(src_path, dest_path)
-
- try:
- with open(src_path, encoding="utf-8") as f:
- content = f.read()
+ self._sync_docs_tree(src_docs, self.meshchatx_docs_dir)
+ self._render_meshchatx_html_exports()
+ except Exception as e:
+ logging.exception(f"Failed to populate MeshChatX docs: {e}")
- html_content = MarkdownRenderer.render(content)
- full_html = f"""<!DOCTYPE html>
-<html class="dark">
-<head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>{file}</title>
- <script src="../assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
- <style>
- body {{ background-color: #111827; color: #f3f4f6; }}
- </style>
-</head>
-<body class="p-4 md:p-8 max-w-4xl mx-auto">
- <div class="max-w-none break-words">
- {html_content}
- </div>
-</body>
-</html>"""
- html_file = os.path.splitext(file)[0] + ".html"
- with open(
- os.path.join(self.meshchatx_docs_dir, html_file),
- "w",
- encoding="utf-8",
- ) as f:
- f.write(full_html)
+ def _sync_docs_tree(self, src_docs, dest_dir):
+ """Copy manifest, markdown, and text files from src_docs into dest_dir."""
+ for root, _, files in os.walk(src_docs):
+ rel_root = os.path.relpath(root, src_docs)
+ target_root = (
+ dest_dir if rel_root == "." else os.path.join(dest_dir, rel_root)
+ )
+ os.makedirs(target_root, exist_ok=True)
+ for file in files:
+ if file == MANIFEST_FILENAME or file.endswith(DOC_FILE_SUFFIXES):
+ src_path = os.path.join(root, file)
+ dest_path = os.path.join(target_root, file)
+ if os.path.abspath(src_path) != os.path.abspath(dest_path):
+ shutil.copy2(src_path, dest_path)
+
+ def _render_meshchatx_html_exports(self):
+ index_links: list[str] = []
+ manifest, _manifest_error = self._read_manifest()
+ if manifest and manifest.get("sections"):
+ for section in sorted(
+ manifest["sections"], key=lambda s: s.get("order", 0)
+ ):
+ section_title = self._localized_text(
+ section.get("title"),
+ manifest.get("default_language", "en"),
+ )
+ if section_title:
+ index_links.append(
+ f'<li class="mb-1 text-sm font-bold text-zinc-300 mt-4">{html.escape(section_title)}</li>',
+ )
+ for item in section.get("items", []):
+ rel_path = item.get("path")
+ if not rel_path or not rel_path.endswith(DOC_FILE_SUFFIXES):
+ continue
+ title = self._localized_text(
+ item.get("title"),
+ item.get("lang") or manifest.get("default_language", "en"),
+ )
+ html_file = self._doc_html_name(rel_path)
+ if title:
+ index_links.append(
+ f'<li class="mb-2 ml-3"><a href="{html.escape(html_file)}" class="text-blue-400 hover:text-blue-300">{html.escape(title)}</a></li>',
+ )
+ self._write_doc_html_export(rel_path)
+ else:
+ for doc in self._collect_flat_docs():
+ rel_path = doc["path"]
+ html_file = self._write_doc_html_export(rel_path)
+ if html_file:
+ label = os.path.basename(rel_path)
index_links.append(
- f'<li class="mb-2"><a href="{html_file}" class="text-blue-400 hover:text-blue-300">{html_file}</a></li>'
+ f'<li class="mb-2"><a href="{html.escape(html_file)}" class="text-blue-400 hover:text-blue-300">{html.escape(label)}</a></li>',
)
- except Exception as e:
- logging.exception(f"Failed to render {file} to HTML: {e}")
- # Generate an index.html so /meshchatx-docs/index.html resolves
- if index_links and os.access(self.meshchatx_docs_dir, os.W_OK):
- index_html = f"""<!DOCTYPE html>
-<html class="dark">
+ if index_links:
+ index_html = self._standalone_html_shell(
+ "MeshChatX Documentation",
+ f'<h1 class="text-2xl font-bold mb-4">MeshChatX Documentation</h1><ul class="list-none pl-0">{"".join(index_links)}</ul>',
+ )
+ with open(
+ os.path.join(self.meshchatx_docs_dir, "index.html"),
+ "w",
+ encoding="utf-8",
+ ) as f:
+ f.write(index_html)
+
+ def _write_doc_html_export(self, rel_path):
+ full_path = os.path.join(self.meshchatx_docs_dir, rel_path)
+ if not os.path.isfile(full_path):
+ return None
+ try:
+ with open(full_path, encoding="utf-8") as f:
+ content = f.read()
+ if rel_path.endswith(".md"):
+ body = MarkdownRenderer.render(content)
+ else:
+ body = f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>"
+ title = os.path.basename(rel_path)
+ html_file = self._doc_html_name(rel_path)
+ html_path = os.path.join(self.meshchatx_docs_dir, html_file)
+ os.makedirs(os.path.dirname(html_path), exist_ok=True)
+ doc_html = self._standalone_html_shell(
+ title, f'<div class="max-w-none break-words">{body}</div>'
+ )
+ with open(html_path, "w", encoding="utf-8") as f:
+ f.write(doc_html)
+ return html_file
+ except Exception as e:
+ logging.exception(f"Failed to render {rel_path} to HTML: {e}")
+ return None
+
+ @staticmethod
+ def _doc_html_name(rel_path):
+ base, _ext = os.path.splitext(rel_path)
+ return f"{base}.html"
+
+ @staticmethod
+ def _standalone_html_shell(title, body_html):
+ safe_title = html.escape(title)
+ return f"""<!DOCTYPE html>
+<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
- <title>MeshChatX Documentation</title>
- <script src="../assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
+ <meta name="color-scheme" content="light dark">
+ <title>{safe_title}</title>
+ <script src="/assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
<style>
- body {{ background-color: #111827; color: #f3f4f6; }}
+ :root {{
+ color-scheme: light dark;
+ --mc-bg: #f8fafc;
+ --mc-fg: #111827;
+ --mc-muted: #6b7280;
+ --mc-code-bg: #1e293b;
+ --mc-code-fg: #f1f5f9;
+ --mc-border: #e5e7eb;
+ }}
+ @media (prefers-color-scheme: dark) {{
+ :root {{
+ --mc-bg: #09090b;
+ --mc-fg: #f4f4f5;
+ --mc-muted: #a1a1aa;
+ --mc-code-bg: #18181b;
+ --mc-code-fg: #f4f4f5;
+ --mc-border: #3f3f46;
+ }}
+ }}
+ body {{
+ background-color: var(--mc-bg);
+ color: var(--mc-fg);
+ }}
+ a {{ color: #2563eb; }}
+ @media (prefers-color-scheme: dark) {{
+ a {{ color: #60a5fa; }}
+ }}
+ pre, code {{
+ background-color: var(--mc-code-bg);
+ color: var(--mc-code-fg);
+ }}
+ th, td {{ border-color: var(--mc-border); }}
</style>
</head>
<body class="p-4 md:p-8 max-w-4xl mx-auto">
- <h1 class="text-2xl font-bold mb-4">MeshChatX Documentation</h1>
- <ul class="list-disc pl-5">
- {"".join(index_links)}
- </ul>
+ {body_html}
</body>
</html>"""
- with open(
- os.path.join(self.meshchatx_docs_dir, "index.html"),
- "w",
- encoding="utf-8",
- ) as f:
- f.write(index_html)
- except Exception as e:
- logging.exception(f"Failed to populate MeshChatX docs: {e}")
def get_status(self):
return {
@@ -309,31 +379,160 @@ class DocsManager:
}
def has_meshchatx_docs(self):
- return (
- any(
- f.endswith((".md", ".txt")) for f in os.listdir(self.meshchatx_docs_dir)
- )
- if os.path.exists(self.meshchatx_docs_dir)
- else False
- )
+ if not os.path.exists(self.meshchatx_docs_dir):
+ return False
+ return len(self._collect_flat_docs()) > 0
+
+ def get_meshchatx_docs_list(self, lang="en"):
+ manifest, manifest_error = self._read_manifest()
+ flat_docs = self._collect_flat_docs()
+ languages = manifest.get("languages") if manifest else None
+ if not languages:
+ languages = [{"code": "en", "name": "English"}]
+ default_language = manifest.get("default_language", "en") if manifest else "en"
+ sections = self._build_sections(manifest, lang, default_language, flat_docs)
+ result = {
+ "docs": flat_docs,
+ "sections": sections,
+ "languages": languages,
+ "default_language": default_language,
+ }
+ if manifest_error:
+ result["manifest_error"] = manifest_error
+ return result
+
+ @staticmethod
+ def _is_safe_doc_path(path):
+ if not path or not isinstance(path, str):
+ return False
+ if "\0" in path:
+ return False
+ normalized = path.replace("\\", "/").strip()
+ if not normalized or normalized.startswith("/"):
+ return False
+ parts = [part for part in normalized.split("/") if part not in ("", ".")]
+ return ".." not in parts
- def get_meshchatx_docs_list(self):
+ def _read_manifest(self):
+ manifest_path = os.path.join(self.meshchatx_docs_dir, MANIFEST_FILENAME)
+ if not os.path.isfile(manifest_path):
+ return None, None
+ try:
+ with open(manifest_path, encoding="utf-8") as f:
+ data = json.load(f)
+ if not isinstance(data, dict):
+ return None, "Manifest must be a JSON object"
+ return data, None
+ except json.JSONDecodeError as e:
+ logging.exception(f"Failed to parse docs manifest: {e}")
+ return None, "Invalid manifest JSON"
+ except OSError as e:
+ logging.exception(f"Failed to read docs manifest: {e}")
+ return None, "Could not read manifest file"
+
+ def _collect_flat_docs(self):
docs = []
if not os.path.exists(self.meshchatx_docs_dir):
return docs
- docs.extend(
- {
- "name": file,
- "path": file,
- "type": "markdown" if file.endswith(".md") else "text",
- }
- for file in os.listdir(self.meshchatx_docs_dir)
- if file.endswith((".md", ".txt"))
- )
- return sorted(docs, key=lambda x: x["name"])
+ for root, _, files in os.walk(self.meshchatx_docs_dir):
+ for file in files:
+ if not file.endswith(DOC_FILE_SUFFIXES):
+ continue
+ file_path = os.path.join(root, file)
+ try:
+ rel_path = os.path.relpath(file_path, self.meshchatx_docs_dir)
+ except ValueError:
+ continue
+ rel_path = rel_path.replace("\\", "/")
+ docs.append(
+ {
+ "name": file,
+ "path": rel_path,
+ "type": "markdown" if file.endswith(".md") else "text",
+ },
+ )
+ return sorted(docs, key=lambda x: x["path"])
+
+ def _build_sections(self, manifest, lang, default_language, flat_docs):
+ if not manifest or not manifest.get("sections"):
+ return [
+ {
+ "id": "all",
+ "title": self._localized_text(
+ {"en": "Guides"}, lang, default_language
+ ),
+ "items": [
+ {
+ "path": doc["path"],
+ "title": self._title_from_path(doc["path"]),
+ "lang": default_language,
+ "type": doc["type"],
+ }
+ for doc in flat_docs
+ ],
+ },
+ ]
+
+ available = {doc["path"] for doc in flat_docs}
+ sections = []
+ for section in sorted(
+ manifest.get("sections", []), key=lambda s: s.get("order", 0)
+ ):
+ items = []
+ for item in section.get("items", []):
+ rel_path = item.get("path")
+ if not rel_path or rel_path not in available:
+ continue
+ item_lang = item.get("lang") or default_language
+ doc_type = "markdown" if rel_path.endswith(".md") else "text"
+ items.append(
+ {
+ "path": rel_path,
+ "title": self._localized_text(
+ item.get("title"),
+ lang,
+ item_lang or default_language,
+ )
+ or self._title_from_path(rel_path),
+ "lang": item_lang,
+ "type": doc_type,
+ },
+ )
+ if items:
+ sections.append(
+ {
+ "id": section.get("id") or section.get("title", "section"),
+ "title": self._localized_text(
+ section.get("title"),
+ lang,
+ default_language,
+ ),
+ "items": items,
+ },
+ )
+ return sections
+
+ @staticmethod
+ def _localized_text(value, lang, fallback="en"):
+ if value is None:
+ return ""
+ if isinstance(value, str):
+ return value
+ if isinstance(value, dict):
+ return (
+ value.get(lang) or value.get(fallback) or next(iter(value.values()), "")
+ )
+ return str(value)
+
+ @staticmethod
+ def _title_from_path(rel_path):
+ base = os.path.basename(rel_path)
+ return os.path.splitext(base)[0].replace("-", " ").replace("_", " ")
def get_doc_content(self, path):
+ if not self._is_safe_doc_path(path):
+ return None
try:
full_path = os.path.realpath(os.path.join(self.meshchatx_docs_dir, path))
base = os.path.realpath(self.meshchatx_docs_dir)
@@ -344,20 +543,28 @@ class DocsManager:
if not os.path.isfile(full_path):
return None
- with open(full_path, encoding="utf-8", errors="ignore") as f:
- content = f.read()
+ try:
+ with open(full_path, encoding="utf-8", errors="ignore") as f:
+ content = f.read()
+ except OSError as e:
+ logging.exception(f"Failed to read MeshChatX doc {path}: {e}")
+ return None
- if path.endswith(".md"):
+ try:
+ if path.endswith(".md"):
+ return {
+ "content": content,
+ "html": MarkdownRenderer.render(content),
+ "type": "markdown",
+ }
return {
"content": content,
- "html": MarkdownRenderer.render(content),
- "type": "markdown",
+ "html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
+ "type": "text",
}
- return {
- "content": content,
- "html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
- "type": "text",
- }
+ except Exception as e:
+ logging.exception(f"Failed to render MeshChatX doc {path}: {e}")
+ return None
def export_docs(self):
"""Build a ZIP archive containing the active Reticulum docs and MeshChatX docs."""
@@ -424,9 +631,11 @@ class DocsManager:
query = query.lower()
if os.path.exists(self.meshchatx_docs_dir):
- for file in os.listdir(self.meshchatx_docs_dir):
- if file.endswith((".md", ".txt")):
- file_path = os.path.join(self.meshchatx_docs_dir, file)
+ for root, _, files in os.walk(self.meshchatx_docs_dir):
+ for file in files:
+ if not file.endswith(DOC_FILE_SUFFIXES):
+ continue
+ file_path = os.path.join(root, file)
try:
with open(
file_path,
@@ -447,7 +656,7 @@ class DocsManager:
results.append(
{
"title": file,
- "path": f"/meshchatx-docs/{file}",
+ "path": f"/meshchatx-docs/{os.path.relpath(file_path, self.meshchatx_docs_dir).replace(os.sep, '/')}",
"snippet": snippet,
"source": "MeshChatX",
},

diff --git a/meshchatx/src/backend/markdown_renderer.py b/meshchatx/src/backend/markdown_renderer.py
index 5e792e8a..80eb8b89 100644
--- a/meshchatx/src/backend/markdown_renderer.py
+++ b/meshchatx/src/backend/markdown_renderer.py
@@ -28,11 +28,30 @@ def _safe_href(url):
class MarkdownRenderer:
"""A simple Markdown to HTML renderer."""
+ _heading_ids: dict[str, int] = {}
+
+ @classmethod
+ def _reset_heading_ids(cls):
+ cls._heading_ids = {}
+
+ @classmethod
+ def _heading_id(cls, text, level):
+ slug_base = re.sub(r"[^\w\s-]", "", html.unescape(text)).strip().lower()
+ slug_base = re.sub(r"[-\s]+", "-", slug_base) or "section"
+ key = f"{level}:{slug_base}"
+ count = cls._heading_ids.get(key, 0)
+ cls._heading_ids[key] = count + 1
+ if count:
+ return f"{slug_base}-{count + 1}"
+ return slug_base
+
@staticmethod
def render(text):
if not text:
return ""
+ MarkdownRenderer._reset_heading_ids()
+
# Escape HTML entities first to prevent XSS
# Use a more limited escape if we want to allow some things,
# but for docs, full escape is safest.
@@ -58,6 +77,8 @@ class MarkdownRenderer:
flags=re.DOTALL,
)
+ text = MarkdownRenderer._render_tables(text)
+
# Horizontal Rules
text = re.sub(
r"^---+$",
@@ -67,27 +88,49 @@ class MarkdownRenderer:
)
# Headers
+ def heading_repl(level, classes):
+ def repl(match):
+ title = match.group(1)
+ heading_id = MarkdownRenderer._heading_id(title, level)
+ return (
+ f'<h{level} id="{heading_id}" class="{classes}">{title}</h{level}>'
+ )
+
+ return repl
+
text = re.sub(
r"^# (.*)$",
- r'<h1 class="text-3xl font-bold mt-8 mb-4 text-gray-900 dark:text-zinc-100">\1</h1>',
+ heading_repl(
+ 1,
+ "text-3xl font-bold mt-8 mb-4 text-gray-900 dark:text-zinc-100 scroll-mt-24",
+ ),
text,
flags=re.MULTILINE,
)
text = re.sub(
r"^## (.*)$",
- r'<h2 class="text-2xl font-bold mt-6 mb-3 text-gray-900 dark:text-zinc-100">\1</h2>',
+ heading_repl(
+ 2,
+ "text-2xl font-bold mt-8 mb-3 text-gray-900 dark:text-zinc-100 scroll-mt-24 border-b border-gray-200 dark:border-zinc-800 pb-2",
+ ),
text,
flags=re.MULTILINE,
)
text = re.sub(
r"^### (.*)$",
- r'<h3 class="text-xl font-bold mt-4 mb-2 text-gray-900 dark:text-zinc-100">\1</h3>',
+ heading_repl(
+ 3,
+ "text-xl font-semibold mt-6 mb-2 text-gray-900 dark:text-zinc-100 scroll-mt-24",
+ ),
text,
flags=re.MULTILINE,
)
text = re.sub(
r"^#### (.*)$",
- r'<h4 class="text-lg font-bold mt-3 mb-2 text-gray-900 dark:text-zinc-100">\1</h4>',
+ heading_repl(
+ 4,
+ "text-lg font-semibold mt-4 mb-2 text-gray-900 dark:text-zinc-100 scroll-mt-24",
+ ),
text,
flags=re.MULTILINE,
)
@@ -207,7 +250,7 @@ class MarkdownRenderer:
continue
# If it already starts with a block tag, don't wrap in <p>
- if re.match(r"^<(h\d|ul|ol|li|blockquote|hr|div)", part):
+ if re.match(r"^<(h\d|ul|ol|li|blockquote|hr|div|table)", part):
processed_parts.append(part)
else:
# Replace single newlines with <br> for line breaks within paragraphs
@@ -223,3 +266,77 @@ class MarkdownRenderer:
text = text.replace(f"[[CB{i}]]", code_html)
return text
+
+ @staticmethod
+ def _is_table_row(line):
+ stripped = line.strip()
+ return (
+ stripped.startswith("|")
+ and stripped.endswith("|")
+ and stripped.count("|") >= 2
+ )
+
+ @staticmethod
+ def _split_table_cells(line):
+ return [cell.strip() for cell in line.strip().strip("|").split("|")]
+
+ @staticmethod
+ def _is_table_separator(line):
+ if not MarkdownRenderer._is_table_row(line):
+ return False
+ cells = MarkdownRenderer._split_table_cells(line)
+ if not cells:
+ return False
+ return all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells)
+
+ @staticmethod
+ def _table_block_to_html(lines):
+ header_cells = MarkdownRenderer._split_table_cells(lines[0])
+ body_rows = [
+ MarkdownRenderer._split_table_cells(row)
+ for row in lines[2:]
+ if MarkdownRenderer._is_table_row(row)
+ ]
+ thead = "".join(
+ f'<th class="px-3 py-2 text-left font-bold">{cell}</th>'
+ for cell in header_cells
+ )
+ tbody_rows = []
+ for row in body_rows:
+ padded = row + [""] * (len(header_cells) - len(row))
+ cells = "".join(
+ f'<td class="px-3 py-2 align-top">{cell}</td>'
+ for cell in padded[: len(header_cells)]
+ )
+ tbody_rows.append(f"<tr>{cells}</tr>")
+ tbody = "".join(tbody_rows)
+ return (
+ '<div class="overflow-x-auto my-6">'
+ '<table class="w-full border-collapse text-sm">'
+ f"<thead><tr>{thead}</tr></thead>"
+ f"<tbody>{tbody}</tbody>"
+ "</table></div>"
+ )
+
+ @staticmethod
+ def _render_tables(text):
+ lines = text.split("\n")
+ out = []
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ if (
+ i + 1 < len(lines)
+ and MarkdownRenderer._is_table_row(line)
+ and MarkdownRenderer._is_table_separator(lines[i + 1])
+ ):
+ block = [line, lines[i + 1]]
+ i += 2
+ while i < len(lines) and MarkdownRenderer._is_table_row(lines[i]):
+ block.append(lines[i])
+ i += 1
+ out.append(MarkdownRenderer._table_block_to_html(block))
+ else:
+ out.append(line)
+ i += 1
+ return "\n".join(out)

diff --git a/meshchatx/src/frontend/components/docs/DocsPage.vue b/meshchatx/src/frontend/components/docs/DocsPage.vue
index 85e6fb69..65933b9c 100644
--- a/meshchatx/src/frontend/components/docs/DocsPage.vue
+++ b/meshchatx/src/frontend/components/docs/DocsPage.vue
@@ -26,7 +26,7 @@
"
@click="activeTab = 'meshchatx'"
>
- MeshChatX
+ {{ $t("docs.tab_meshchatx") }}
</button>
<button
class="px-3 py-1 text-[10px] font-bold uppercase tracking-wider rounded-md transition-all"
@@ -37,7 +37,7 @@
"
@click="activeTab = 'reticulum'"
>
- Reticulum
+ {{ $t("docs.tab_reticulum") }}
</button>
</div>
@@ -50,7 +50,7 @@
v-model="searchQuery"
type="text"
class="block w-full pl-8 pr-8 py-1.5 border border-gray-200 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 text-[11px] focus:outline-hidden focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
- placeholder="Search documentation..."
+ :placeholder="$t('docs.search_placeholder')"
@input="debounceSearch"
/>
<div v-if="isSearching" class="absolute inset-y-0 right-0 pr-2.5 flex items-center">
@@ -84,7 +84,7 @@
>
<MaterialDesignIcon icon-name="history" class="w-4 h-4 md:w-5 md:h-5" />
<span class="hidden xl:inline text-[10px] font-bold uppercase">{{
- status.current_version || "Default"
+ status.current_version || $t("docs.default_version")
}}</span>
</button>
<div
@@ -94,7 +94,9 @@
<div
class="p-2 border-b border-gray-100 dark:border-zinc-700 bg-gray-50/50 dark:bg-zinc-800/50"
>
- <span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">Versions</span>
+ <span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider">{{
+ $t("docs.versions")
+ }}</span>
</div>
<div class="max-h-64 overflow-y-auto py-1">
<button
@@ -130,7 +132,7 @@
v-if="status.versions.length === 0"
class="px-4 py-3 text-center text-gray-500 text-[10px]"
>
- No versions available
+ {{ $t("docs.no_versions") }}
</div>
</div>
<div
@@ -140,7 +142,7 @@
class="flex items-center justify-center gap-2 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg cursor-pointer transition-colors text-[10px] font-bold uppercase"
>
<MaterialDesignIcon icon-name="upload" class="w-3.5 h-3.5" />
- <span>Upload ZIP</span>
+ <span>{{ $t("docs.upload_zip") }}</span>
<input type="file" accept=".zip" class="hidden" @change="handleZipUpload" />
</label>
</div>
@@ -216,7 +218,7 @@
class="hidden sm:flex items-center px-2.5 py-1.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg hover:opacity-90 transition-opacity font-bold text-[10px] shadow-xs"
>
<MaterialDesignIcon icon-name="open-in-new" class="w-3 h-3 mr-1.5" />
- Open
+ {{ $t("docs.open_external") }}
</a>
</div>
</div>
@@ -238,7 +240,7 @@
"
@click="activeTab = 'meshchatx'"
>
- MeshChatX
+ {{ $t("docs.tab_meshchatx") }}
</button>
<button
class="flex-1 md:flex-none px-4 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-md transition-all"
@@ -249,7 +251,7 @@
"
@click="activeTab = 'reticulum'"
>
- Reticulum
+ {{ $t("docs.tab_reticulum") }}
</button>
</div>
@@ -262,7 +264,7 @@
v-model="searchQuery"
type="text"
class="block w-full pl-9 pr-9 py-2 border border-gray-200 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 text-xs focus:outline-hidden focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
- placeholder="Search all documentation..."
+ :placeholder="$t('docs.search_placeholder_mobile')"
@input="debounceSearch"
/>
<div v-if="isSearching" class="absolute inset-y-0 right-0 pr-3 flex items-center">
@@ -300,10 +302,12 @@
>
<div class="max-w-2xl mx-auto p-6 space-y-6">
<div class="flex items-center justify-between px-2">
- <h2 class="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Search Results</h2>
+ <h2 class="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
+ {{ $t("docs.search_results") }}
+ </h2>
<span
class="text-[10px] font-bold text-blue-500 px-2 py-0.5 bg-blue-50 dark:bg-blue-900/20 rounded-full"
- >{{ searchResults.length }} matches</span
+ >{{ $t("docs.matches_count", { count: searchResults.length }) }}</span
>
</div>
<div class="space-y-2">
@@ -341,7 +345,7 @@
<!-- No Results State -->
<div
- v-if="searchQuery && !isSearching && searchResults.length === 0"
+ v-if="searchQuery && !isSearching && searchResults.length === 0 && !searchError"
class="absolute inset-0 z-20 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center p-8 text-center"
>
<div
@@ -349,13 +353,30 @@
>
<MaterialDesignIcon icon-name="text-search" class="w-8 h-8 text-gray-300 dark:text-zinc-600" />
</div>
- <h3 class="text-sm font-medium text-gray-900 dark:text-zinc-100">No results found</h3>
- <p class="text-xs text-gray-500 dark:text-zinc-400 mt-1">Try different keywords or check spelling.</p>
+ <h3 class="text-sm font-medium text-gray-900 dark:text-zinc-100">{{ $t("docs.no_results") }}</h3>
+ <p class="text-xs text-gray-500 dark:text-zinc-400 mt-1">{{ $t("docs.no_results_hint") }}</p>
<button
class="mt-4 text-xs font-bold text-blue-500 hover:text-blue-600 transition-colors"
@click="clearSearch"
>
- Clear Search
+ {{ $t("docs.clear_search") }}
+ </button>
+ </div>
+
+ <div
+ v-if="searchError && searchQuery"
+ class="absolute inset-0 z-20 bg-white dark:bg-zinc-900 flex flex-col items-center justify-center p-8 text-center"
+ >
+ <div class="w-16 h-16 bg-red-50 dark:bg-red-950/30 rounded-full flex items-center justify-center mb-4">
+ <MaterialDesignIcon icon-name="alert-circle-outline" class="w-8 h-8 text-red-400" />
+ </div>
+ <h3 class="text-sm font-medium text-gray-900 dark:text-zinc-100">{{ $t("docs.search_failed") }}</h3>
+ <p class="text-xs text-gray-500 dark:text-zinc-400 mt-1 max-w-sm">{{ searchError }}</p>
+ <button
+ class="mt-4 text-xs font-bold text-blue-500 hover:text-blue-600 transition-colors"
+ @click="clearSearch"
+ >
+ {{ $t("docs.clear_search") }}
</button>
</div>
@@ -381,7 +402,7 @@
class="text-[10px] font-bold text-red-500/60 hover:text-red-500 uppercase tracking-widest transition-colors"
@click="dismissError"
>
- Dismiss
+ {{ $t("docs.dismiss") }}
</button>
</div>
</div>
@@ -408,71 +429,166 @@
<h3 class="text-lg font-bold text-gray-900 dark:text-zinc-100 mb-1">
{{ $t("docs.status_extracting") }}
</h3>
- <p class="text-sm text-gray-500 dark:text-zinc-400">{{ status.progress }}% Complete</p>
+ <p class="text-sm text-gray-500 dark:text-zinc-400">
+ {{ $t("docs.complete_percent", { percent: status.progress }) }}
+ </p>
</div>
<!-- MeshChatX Docs View -->
<div v-if="activeTab === 'meshchatx' && !searchQuery" class="flex h-full overflow-hidden">
- <!-- Doc Sidebar (mobile hidden) -->
- <div
- class="hidden md:flex flex-col w-64 border-r border-gray-200 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/50"
+ <!-- Section sidebar -->
+ <aside
+ class="hidden lg:flex flex-col w-72 shrink-0 border-r border-sem-border bg-sem-canvas/80 dark:bg-zinc-950/80"
>
- <div class="p-4 border-b border-gray-200 dark:border-zinc-800">
- <h3 class="text-[10px] font-bold text-gray-400 uppercase tracking-widest">MeshChatX Docs</h3>
- </div>
- <div class="flex-1 overflow-y-auto p-2 space-y-1">
- <button
- v-for="doc in meshchatxDocs"
- :key="doc.path"
- class="w-full text-left px-3 py-2 rounded-xl text-xs transition-all flex items-center space-x-3"
- :class="
- selectedDocPath === doc.path
- ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 font-bold shadow-xs'
- : 'text-gray-600 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800'
- "
- @click="selectDoc(doc.path)"
+ <div class="p-4 border-b border-sem-border space-y-3">
+ <h3 class="text-[10px] font-bold text-sem-fg-muted uppercase tracking-widest">
+ {{ $t("docs.sections_title") }}
+ </h3>
+ <p
+ v-if="manifestWarning"
+ class="text-[11px] leading-relaxed text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900/40 rounded-lg px-2.5 py-2"
>
- <MaterialDesignIcon
- :icon-name="doc.type === 'markdown' ? 'language-markdown' : 'file-document-outline'"
- class="w-4 h-4"
- />
- <span class="truncate">{{ (doc.name || "").replace(/\.(md|txt)$/, "") }}</span>
- </button>
- </div>
- </div>
-
- <!-- Doc Content -->
- <div class="flex-1 flex flex-col bg-white dark:bg-zinc-900 overflow-hidden relative">
- <!-- Mobile Selector -->
- <div class="md:hidden p-3 border-b border-gray-200 dark:border-zinc-800">
- <select
- v-model="selectedDocPath"
- class="w-full bg-gray-50 dark:bg-zinc-800 border-none rounded-lg text-xs font-bold p-2"
- @change="selectDoc(selectedDocPath)"
+ {{ manifestWarning }}
+ </p>
+ <p
+ v-if="meshchatxListError"
+ class="text-[11px] leading-relaxed text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900/40 rounded-lg px-2.5 py-2"
>
- <option v-for="doc in meshchatxDocs" :key="doc.path" :value="doc.path">
- {{ (doc.name || "").replace(/\.(md|txt)$/, "") }}
- </option>
- </select>
+ {{ meshchatxListError }}
+ </p>
+ <div v-if="docLanguages.length > 1" class="flex flex-wrap gap-1.5">
+ <button
+ v-for="lang in docLanguages"
+ :key="lang.code"
+ type="button"
+ class="px-2 py-1 rounded-md text-[10px] font-bold uppercase tracking-wide transition-colors"
+ :class="
+ meshchatxDocsLang === lang.code
+ ? 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300'
+ : 'bg-sem-surface-muted text-sem-fg-muted hover:text-sem-fg'
+ "
+ @click="setMeshchatxDocsLang(lang.code)"
+ >
+ {{ lang.code }}
+ </button>
+ </div>
</div>
+ <nav class="flex-1 overflow-y-auto p-3 space-y-5 custom-scroll">
+ <div v-for="section in visibleDocSections" :key="section.id">
+ <p class="px-2 mb-2 text-[10px] font-bold uppercase tracking-widest text-sem-fg-muted">
+ {{ section.title }}
+ </p>
+ <div class="space-y-0.5">
+ <button
+ v-for="item in section.items"
+ :key="item.path"
+ type="button"
+ class="w-full text-left px-3 py-2 rounded-xl text-xs transition-all flex items-center gap-2.5"
+ :class="
+ selectedDocPath === item.path
+ ? 'bg-cyan-50 dark:bg-cyan-950/40 text-cyan-700 dark:text-cyan-300 font-semibold shadow-xs ring-1 ring-cyan-200/80 dark:ring-cyan-800/60'
+ : 'text-sem-fg-muted hover:bg-sem-surface-muted hover:text-sem-fg'
+ "
+ @click="selectDoc(item.path)"
+ >
+ <MaterialDesignIcon
+ :icon-name="
+ item.type === 'markdown' ? 'language-markdown' : 'file-document-outline'
+ "
+ class="w-4 h-4 shrink-0 opacity-70"
+ />
+ <span class="truncate">{{ item.title }}</span>
+ </button>
+ </div>
+ </div>
+ </nav>
+ </aside>
- <div v-if="selectedDocContent" class="flex-1 overflow-y-auto p-6 md:p-10 scroll-smooth">
- <div class="max-w-3xl mx-auto">
- <div class="max-w-none wrap-break-word" v-html="selectedDocContent.html"></div>
+ <!-- Doc content -->
+ <div class="flex-1 flex min-w-0 bg-sem-surface dark:bg-zinc-900 overflow-hidden">
+ <div class="flex-1 flex flex-col min-w-0 overflow-hidden">
+ <div class="lg:hidden p-3 border-b border-sem-border bg-sem-surface space-y-2">
+ <label class="text-[10px] font-bold uppercase tracking-widest text-sem-fg-muted">{{
+ $t("docs.sections_title")
+ }}</label>
+ <select
+ v-model="selectedDocPath"
+ class="w-full bg-sem-surface-muted border border-sem-border rounded-xl text-xs font-medium p-2.5 text-sem-fg"
+ @change="selectDoc(selectedDocPath)"
+ >
+ <optgroup
+ v-for="section in visibleDocSections"
+ :key="section.id"
+ :label="section.title"
+ >
+ <option v-for="item in section.items" :key="item.path" :value="item.path">
+ {{ item.title }}
+ </option>
+ </optgroup>
+ </select>
+ </div>
+
+ <div
+ v-if="selectedDocContent"
+ ref="docContentScroller"
+ class="flex-1 overflow-y-auto scroll-smooth custom-scroll"
+ >
+ <div class="max-w-3xl mx-auto px-5 py-8 md:px-10 md:py-12">
+ <article
+ ref="docsProse"
+ class="docs-prose max-w-none wrap-break-word"
+ v-html="selectedDocContent.html"
+ ></article>
+ </div>
+ </div>
+ <div
+ v-else-if="docLoadError"
+ class="flex-1 flex flex-col items-center justify-center p-8 text-center"
+ >
+ <MaterialDesignIcon icon-name="alert-circle-outline" class="w-12 h-12 mb-4 text-red-400" />
+ <h3 class="text-sm font-semibold text-sem-fg">{{ $t("docs.load_doc_failed") }}</h3>
+ <p class="text-xs mt-2 max-w-sm text-sem-fg-muted">{{ docLoadError }}</p>
+ </div>
+ <div
+ v-else-if="meshchatxDocs.length > 0"
+ class="flex-1 flex flex-col items-center justify-center p-8 text-center text-sem-fg-muted"
+ >
+ <MaterialDesignIcon icon-name="book-open-outline" class="w-12 h-12 mb-4 opacity-40" />
+ <h3 class="text-sm font-semibold text-sem-fg">{{ $t("docs.select_doc") }}</h3>
+ </div>
+ <div
+ v-else
+ class="flex-1 flex flex-col items-center justify-center p-8 text-center text-sem-fg-muted"
+ >
+ <MaterialDesignIcon icon-name="alert-circle-outline" class="w-12 h-12 mb-4 opacity-40" />
+ <h3 class="text-sm font-semibold text-sem-fg">{{ $t("docs.no_docs_found") }}</h3>
+ <p class="text-xs mt-1 max-w-xs">{{ $t("docs.no_docs_hint") }}</p>
</div>
</div>
- <div
- v-else-if="meshchatxDocs.length > 0"
- class="flex-1 flex flex-col items-center justify-center p-8 text-center opacity-50"
+
+ <!-- On-page table of contents -->
+ <aside
+ v-if="docToc.length > 0 && selectedDocContent"
+ class="hidden xl:flex flex-col w-56 shrink-0 border-l border-sem-border bg-sem-canvas/50 dark:bg-zinc-950/50"
>
- <MaterialDesignIcon icon-name="book-open-outline" class="w-12 h-12 mb-4 text-gray-300" />
- <h3 class="text-sm font-bold">Select a document to read</h3>
- </div>
- <div v-else class="flex-1 flex flex-col items-center justify-center p-8 text-center opacity-50">
- <MaterialDesignIcon icon-name="alert-circle-outline" class="w-12 h-12 mb-4 text-gray-300" />
- <h3 class="text-sm font-bold">No MeshChatX docs found</h3>
- <p class="text-xs mt-1">Place .md or .txt files in your docs folder.</p>
- </div>
+ <div class="p-4 border-b border-sem-border">
+ <h3 class="text-[10px] font-bold text-sem-fg-muted uppercase tracking-widest">
+ {{ $t("docs.on_this_page") }}
+ </h3>
+ </div>
+ <nav class="flex-1 overflow-y-auto p-3 space-y-1 custom-scroll">
+ <a
+ v-for="entry in docToc"
+ :key="entry.id"
+ :href="`#${entry.id}`"
+ class="block py-1 text-xs text-sem-fg-muted hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors"
+ :class="entry.level === 3 ? 'pl-3' : ''"
+ @click.prevent="scrollToHeading(entry.id)"
+ >
+ {{ entry.text }}
+ </a>
+ </nav>
+ </aside>
</div>
</div>
@@ -483,18 +599,22 @@
ref="docsFrame"
:src="localDocsUrl"
class="w-full h-full border-none opacity-0 transition-opacity duration-1000"
- @load="$el.querySelector('iframe').style.opacity = '1'"
+ @load="onReticulumFrameLoad"
></iframe>
<div
- v-else-if="status.status !== 'extracting'"
+ v-else-if="
+ activeTab === 'reticulum' && !status.has_docs && status.status !== 'extracting' && !searchQuery
+ "
class="h-full flex flex-col items-center justify-center p-8 text-center space-y-4"
>
<div class="w-16 h-16 bg-gray-50 dark:bg-zinc-800/50 rounded-full flex items-center justify-center">
<MaterialDesignIcon icon-name="book-outline" class="w-8 h-8 text-gray-300 dark:text-zinc-600" />
</div>
<div>
- <h3 class="text-sm font-medium text-gray-900 dark:text-zinc-100">Reticulum Manual</h3>
+ <h3 class="text-sm font-medium text-gray-900 dark:text-zinc-100">
+ {{ $t("docs.reticulum_manual") }}
+ </h3>
<p class="text-xs text-gray-500 dark:text-zinc-400 mt-1 max-w-[260px]">
{{ $t("docs.empty_state_hint") }}
</p>
@@ -543,6 +663,15 @@ export default {
searchTimeout: null,
activeTab: "meshchatx",
meshchatxDocs: [],
+ docSections: [],
+ docLanguages: [],
+ defaultDocsLanguage: "en",
+ meshchatxDocsLang: "en",
+ docToc: [],
+ meshchatxListError: null,
+ docLoadError: null,
+ manifestWarning: null,
+ searchError: null,
selectedDocPath: null,
selectedDocContent: null,
selectedReticulumPath: null,
@@ -583,6 +712,26 @@ export default {
reticulumDocsQueryParam() {
return this.$route?.query?.reticulum;
},
+ visibleDocSections() {
+ const lang = this.meshchatxDocsLang;
+ const fallback = this.defaultDocsLanguage || "en";
+ return this.docSections
+ .map((section) => ({
+ ...section,
+ items: (section.items || []).filter(
+ (item) => item.lang === lang || item.lang === fallback || lang === fallback
+ ),
+ }))
+ .filter((section) => section.items.length > 0);
+ },
+ firstDocPath() {
+ for (const section of this.visibleDocSections) {
+ if (section.items?.length) {
+ return section.items[0].path;
+ }
+ }
+ return this.meshchatxDocs[0]?.path || null;
+ },
},
watch: {
reticulumDocsQueryParam() {
@@ -622,28 +771,109 @@ export default {
this.status = { ...this.status, last_error: null };
},
async fetchMeshChatXDocs() {
+ this.meshchatxListError = null;
+ this.manifestWarning = null;
try {
- const response = await window.api.get("/api/v1/meshchatx-docs/list");
- this.meshchatxDocs = response.data;
+ const response = await window.api.get("/api/v1/meshchatx-docs/list", {
+ params: { lang: this.meshchatxDocsLang },
+ });
+ const data = response.data;
+ if (Array.isArray(data)) {
+ this.meshchatxDocs = data;
+ this.docSections = [];
+ this.docLanguages = [{ code: "en", name: "English" }];
+ } else {
+ this.meshchatxDocs = data.docs || [];
+ this.docSections = data.sections || [];
+ this.docLanguages = data.languages || [{ code: "en", name: "English" }];
+ this.defaultDocsLanguage = data.default_language || "en";
+ if (data.manifest_error) {
+ this.manifestWarning = this.$t("docs.manifest_warning");
+ }
+ }
+ if (!this.docLanguages.some((l) => l.code === this.meshchatxDocsLang)) {
+ this.meshchatxDocsLang = this.defaultDocsLanguage || "en";
+ }
if (this.meshchatxDocs.length > 0 && !this.selectedDocPath) {
- this.selectDoc(this.meshchatxDocs[0].path);
+ const start = this.firstDocPath;
+ if (start) {
+ this.selectDoc(start);
+ }
}
} catch (error) {
console.error("Failed to fetch MeshChatX docs list:", error);
+ this.meshchatxDocs = [];
+ this.docSections = [];
+ this.meshchatxListError = error.response?.data?.error || this.$t("docs.load_list_failed");
}
},
+ async setMeshchatxDocsLang(langCode) {
+ if (this.meshchatxDocsLang === langCode) {
+ return;
+ }
+ this.meshchatxDocsLang = langCode;
+ this.selectedDocPath = null;
+ this.selectedDocContent = null;
+ this.docToc = [];
+ await this.fetchMeshChatXDocs();
+ },
async selectDoc(path) {
+ if (!path) {
+ return;
+ }
this.selectedDocPath = path;
+ this.docLoadError = null;
try {
const response = await window.api.get("/api/v1/meshchatx-docs/content", {
params: { path },
});
+ if (!response.data?.html && !response.data?.content) {
+ throw new Error("Empty document response");
+ }
this.selectedDocContent = response.data;
+ this.docToc = this.extractDocToc(this.selectedDocContent?.html || "");
} catch (error) {
console.error("Failed to fetch doc content:", error);
- this.selectedDocContent = {
- html: '<div class="text-red-500 font-bold">Failed to load document.</div>',
- };
+ this.docLoadError = error.response?.data?.error || this.$t("docs.load_doc_failed");
+ this.selectedDocContent = null;
+ this.docToc = [];
+ }
+ },
+ extractDocToc(htmlContent) {
+ if (!htmlContent) {
+ return [];
+ }
+ try {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(htmlContent, "text/html");
+ return Array.from(doc.querySelectorAll("h2, h3"))
+ .map((heading) => ({
+ id: heading.id,
+ text: heading.textContent?.trim() || "",
+ level: heading.tagName === "H2" ? 2 : 3,
+ }))
+ .filter((entry) => entry.id && entry.text);
+ } catch {
+ return [];
+ }
+ },
+ scrollToHeading(id) {
+ const prose = this.$refs.docsProse;
+ if (!prose || typeof prose.querySelector !== "function") {
+ return;
+ }
+ if (!id || !/^[a-z0-9-]+$/.test(id)) {
+ return;
+ }
+ const target = prose.querySelector(`#${id}`);
+ if (target) {
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+ },
+ onReticulumFrameLoad() {
+ const frame = this.$refs.docsFrame;
+ if (frame && frame.style) {
+ frame.style.opacity = "1";
}
},
async switchVersion(version) {
@@ -664,7 +894,7 @@ export default {
}
},
async deleteVersion(version) {
- if (!confirm(`Are you sure you want to delete version "${version}"?`)) {
+ if (!confirm(this.$t("docs.confirm_delete_version", { version }))) {
return;
}
@@ -681,7 +911,7 @@ export default {
const file = event.target.files[0];
if (!file) return;
- const version = prompt("Enter version name for this upload:", `upload-${Date.now()}`);
+ const version = prompt(this.$t("docs.prompt_version_name"), `upload-${Date.now()}`);
if (!version) return;
const formData = new FormData();
@@ -696,7 +926,8 @@ export default {
this.fetchStatus();
} catch (error) {
console.error("Failed to upload docs zip:", error);
- alert("Failed to upload docs zip: " + (error.response?.data?.error || error.message));
+ const message = error.response?.data?.error || error.message || "";
+ alert(this.$t("docs.failed_upload_alert", { message }));
}
},
async exportDocs() {
@@ -745,6 +976,7 @@ export default {
async performSearch() {
if (!this.searchQuery) return;
this.isSearching = true;
+ this.searchError = null;
try {
const response = await window.api.get("/api/v1/docs/search", {
params: {
@@ -752,9 +984,11 @@ export default {
lang: this.currentLang,
},
});
- this.searchResults = response.data.results;
+ this.searchResults = response.data?.results || [];
} catch (error) {
console.error("Search failed:", error);
+ this.searchResults = [];
+ this.searchError = error.response?.data?.error || this.$t("docs.search_failed");
} finally {
this.isSearching = false;
}
@@ -762,6 +996,7 @@ export default {
clearSearch() {
this.searchQuery = "";
this.searchResults = [];
+ this.searchError = null;
},
applyDocumentationRouteQuery() {
const q = this.reticulumDocsQueryParam;
@@ -821,69 +1056,78 @@ export default {
</script>
<style scoped>
-/* Ensure the iframe fills the container and respects dark mode if possible */
iframe {
color-scheme: light dark;
}
-/* Markdown styling for the rendered HTML */
-:deep(.max-w-none) pre {
- color: #f4f4f5 !important; /* zinc-100 */
+:deep(.docs-prose) {
+ color: var(--mc-text-secondary);
+ font-size: 0.9375rem;
+ line-height: 1.7;
}
-:deep(.max-w-none) pre code {
+:deep(.docs-prose h1) {
+ letter-spacing: -0.02em;
+}
+
+:deep(.docs-prose h2 a),
+:deep(.docs-prose h3 a) {
+ color: inherit;
+ text-decoration: none;
+}
+
+:deep(.docs-prose pre) {
+ color: #f4f4f5 !important;
+}
+
+:deep(.docs-prose pre code) {
color: inherit !important;
}
-:deep(.max-w-none) code {
+:deep(.docs-prose code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
-.dark :deep(.max-w-none) p {
- color: #e4e4e7; /* zinc-200 */
+.dark :deep(.docs-prose p) {
+ color: #e4e4e7;
}
-.dark :deep(.max-w-none) h1,
-.dark :deep(.max-w-none) h2,
-.dark :deep(.max-w-none) h3,
-.dark :deep(.max-w-none) h4 {
- color: #f4f4f5; /* zinc-100 */
+.dark :deep(.docs-prose h1),
+.dark :deep(.docs-prose h2),
+.dark :deep(.docs-prose h3),
+.dark :deep(.docs-prose h4) {
+ color: #f4f4f5;
}
-/* Markdown table styling */
-:deep(.max-w-none) table {
+:deep(.docs-prose table) {
width: 100%;
border-collapse: collapse;
- margin: 1rem 0;
+ margin: 1.25rem 0;
font-size: 0.875rem;
}
-:deep(.max-w-none) th,
-:deep(.max-w-none) td {
- border: 1px solid #d1d5db;
+:deep(.docs-prose th),
+:deep(.docs-prose td) {
+ border: 1px solid var(--mc-border);
padding: 0.5rem 0.75rem;
text-align: left;
}
-:deep(.max-w-none) th {
- background-color: #f3f4f6;
+:deep(.docs-prose th) {
+ background-color: var(--mc-surface-muted);
font-weight: 700;
}
-:deep(.max-w-none) tr:nth-child(even) {
- background-color: #f9fafb;
-}
-
-.dark :deep(.max-w-none) th,
-.dark :deep(.max-w-none) td {
- border-color: #3f3f46;
+:deep(.docs-prose tr:nth-child(even)) {
+ background-color: color-mix(in srgb, var(--mc-surface-muted) 65%, transparent);
}
-.dark :deep(.max-w-none) th {
- background-color: #27272a;
+:deep(.docs-prose a) {
+ text-decoration-thickness: 1px;
+ text-underline-offset: 2px;
}
-.dark :deep(.max-w-none) tr:nth-child(even) {
- background-color: #18181b;
+:deep(.docs-prose blockquote) {
+ border-left-color: color-mix(in srgb, var(--mc-accent) 55%, transparent);
}
</style>

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index e42b0809..3c8a9cee 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1697,6 +1697,31 @@
"docs": {
"title": "Dokumentation",
"subtitle": "Reticulum-Handbuch und MeshChatX-Anleitungen, nach Einrichtung offline lesbar.",
+ "tab_meshchatx": "MeshChatX",
+ "tab_reticulum": "Reticulum",
+ "search_placeholder": "Dokumentation durchsuchen...",
+ "search_placeholder_mobile": "Gesamte Dokumentation durchsuchen...",
+ "search_results": "Suchergebnisse",
+ "matches_count": "{count} Treffer",
+ "no_results": "Keine Ergebnisse",
+ "no_results_hint": "Andere Stichwörter versuchen oder Schreibweise prüfen.",
+ "clear_search": "Suche löschen",
+ "versions": "Versionen",
+ "no_versions": "Keine Versionen verfügbar",
+ "default_version": "Standard",
+ "upload_zip": "ZIP hochladen",
+ "open_external": "Öffnen",
+ "dismiss": "Schließen",
+ "sections_title": "Anleitungen",
+ "on_this_page": "Auf dieser Seite",
+ "language_label": "Sprache der Anleitungen",
+ "select_doc": "Wähle eine Anleitung in der Seitenleiste",
+ "no_docs_found": "Keine MeshChatX-Anleitungen gefunden",
+ "no_docs_hint": "Anleitungen werden beim Start aus dem docs-Ordner kopiert.",
+ "reticulum_manual": "Reticulum-Handbuch",
+ "complete_percent": "{percent} % abgeschlossen",
+ "confirm_delete_version": "Dokumentationsversion \"{version}\" löschen?",
+ "prompt_version_name": "Versionsname für diesen Upload eingeben:",
"status_title": "Dokumentationsstatus",
"status_extracting": "Wird entpackt...",
"status_available": "Offline-Handbuch verfügbar",
@@ -1707,7 +1732,12 @@
"error": "Fehler",
"failed_upload_docs": "Hochladen der Dokumentation fehlgeschlagen",
"docs_link_copied": "Dokumentationslink in Zwischenablage kopiert",
- "failed_copy_link": "Link kopieren fehlgeschlagen"
+ "failed_copy_link": "Link kopieren fehlgeschlagen",
+ "load_list_failed": "Anleitungsliste konnte nicht geladen werden. Bitte später erneut versuchen.",
+ "load_doc_failed": "Diese Anleitung konnte nicht geladen werden.",
+ "search_failed": "Suche fehlgeschlagen. Verbindung prüfen und erneut versuchen.",
+ "manifest_warning": "Die Dokumentationsindex-Datei konnte nicht gelesen werden. Verfügbare Dateien werden ohne Abschnittsgruppierung angezeigt.",
+ "failed_upload_alert": "Hochladen der Dokumentation fehlgeschlagen: {message}"
},
"licenses": {
"section_label": "Rechtliches",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 9f512820..24b729f6 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -1809,6 +1809,31 @@
"docs": {
"title": "Documentation",
"subtitle": "Reticulum manual and MeshChatX guides, available offline after setup.",
+ "tab_meshchatx": "MeshChatX",
+ "tab_reticulum": "Reticulum",
+ "search_placeholder": "Search documentation...",
+ "search_placeholder_mobile": "Search all documentation...",
+ "search_results": "Search results",
+ "matches_count": "{count} matches",
+ "no_results": "No results found",
+ "no_results_hint": "Try different keywords or check spelling.",
+ "clear_search": "Clear search",
+ "versions": "Versions",
+ "no_versions": "No versions available",
+ "default_version": "Default",
+ "upload_zip": "Upload ZIP",
+ "open_external": "Open",
+ "dismiss": "Dismiss",
+ "sections_title": "Guides",
+ "on_this_page": "On this page",
+ "language_label": "Guide language",
+ "select_doc": "Select a guide from the sidebar",
+ "no_docs_found": "No MeshChatX guides found",
+ "no_docs_hint": "Guides are copied from the docs folder when the app starts.",
+ "reticulum_manual": "Reticulum manual",
+ "complete_percent": "{percent}% complete",
+ "confirm_delete_version": "Delete documentation version \"{version}\"?",
+ "prompt_version_name": "Enter a version name for this upload:",
"status_title": "Documentation Status",
"status_extracting": "Extracting Documentation...",
"status_available": "Offline Manual Available",
@@ -1819,7 +1844,12 @@
"error": "Error",
"failed_upload_docs": "Failed to upload documentation",
"docs_link_copied": "Documentation link copied to clipboard",
- "failed_copy_link": "Failed to copy link"
+ "failed_copy_link": "Failed to copy link",
+ "load_list_failed": "Could not load the guide list. Try again in a moment.",
+ "load_doc_failed": "Could not load this guide.",
+ "search_failed": "Search failed. Check your connection and try again.",
+ "manifest_warning": "The documentation index file could not be read. Showing available files without section grouping.",
+ "failed_upload_alert": "Failed to upload documentation: {message}"
},
"licenses": {
"section_label": "Legal",

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md b/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md
new file mode 100644
index 00000000..6267173d
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/architecture.md
@@ -0,0 +1,146 @@
+# Architecture and design
+
+MeshChatX is a heavily extended fork of Reticulum MeshChat. The goals below shaped how the codebase is organized.
+
+## Design goals
+
+- Keep a local-first runtime that works on desktop, mobile, containers, and single-board computers.
+- Preserve Reticulum and LXMF semantics while improving usability and operational tooling.
+- Support multiple identities in one process without cross-identity data leakage.
+- Keep the Python backend and Vue frontend independently testable.
+- Run in constrained environments with predictable SQLite behaviour.
+
+## Process overview
+
+One Python process owns the web server, Reticulum stack, and all per-identity managers. The Vue frontend is static assets served from `meshchatx/public/` after a Vite build.
+
+```
+ReticulumMeshChat (meshchat.py)
+ |
+ +-- HTTP routes (/api/v1/*, static files)
+ +-- WebSocket (/ws, /ws/telephone/audio)
+ +-- IdentityContext (per active identity)
+ | +-- SQLite via database layer
+ | +-- LXMRouter
+ | +-- TelephoneManager (LXST)
+ | +-- Domain managers (messages, map, docs, RRC, ...)
+ +-- Shared Reticulum instance (~/.reticulum by default)
+```
+
+Optional **Electron** wraps the same backend binary and loads the UI from the local HTTPS server.
+
+## Application shell
+
+`ReticulumMeshChat` in `meshchatx/meshchat.py` is the orchestration layer. It registers routes, starts and stops identity contexts, wires crash recovery, and coordinates shared process concerns.
+
+Path helpers live in `meshchatx/src/path_utils.py`, `ssl_self_signed.py`, and `env_utils.py`. `meshchat.py` re-exports them for compatibility.
+
+## Identity-scoped context
+
+`IdentityContext` in `meshchatx/src/backend/identity_context.py` encapsulates everything tied to one cryptographic identity:
+
+- Storage under `storage/identities/<identity_hash>/`
+- Identity-local SQLite database (schema version tracked in migrations)
+- LXMF router state and propagation directories
+- Manager instances for messages, announces, docs, maps, forwarding, bots, RRC, Nomad page nodes, and more
+
+Switching identities tears down the old context and loads another. Global mutable state that could leak between identities is avoided by design.
+
+## Manager-centric domain logic
+
+Feature behaviour lives in modules under `meshchatx/src/backend/`. Examples include message handling, announce trimming, documentation, maps, page nodes, telemetry, interfaces, forwarding aliases, and RN-specific tool handlers.
+
+`meshchat.py` should stay focused on transport and lifecycle. Business rules belong in managers where they can be unit tested.
+
+## Persistence
+
+- **Engine:** SQLite with explicit SQL and migrations (no ORM).
+- **Schema:** Versioned migrations run during startup and identity setup.
+- **Backups:** Automatic and manual database backups under `database-backups/`.
+- **Recovery:** `--auto-recover`, emergency mode, and Electron crash UI can restore from backups.
+
+## HTTP API
+
+Routes are registered explicitly on the aiohttp application. Categories include:
+
+- Application status and configuration
+- Authentication and session management
+- LXMF messaging and conversations
+- Telephone and voicemail
+- Interfaces and Reticulum configuration
+- Nomad Network and page nodes
+- RRC client and server
+- Tools (ping, RNPath, RNCP, RNSH, translator, bots)
+- Documentation and maintenance
+
+The frontend uses `fetch` via `apiClient.js` with CSRF tokens on mutating requests.
+
+## WebSockets
+
+The UI connects to `/ws` for low-latency updates. Event types include new LXMF messages, identity switches, telephone state, RRC activity, Nomad download progress, RNCP transfers, and plugin events. Handlers are registered in `wsEventRegistry.js` and dispatched through `wsEventBridge.js`.
+
+Audio calls can use `/ws/telephone/audio` for browser-side codec bridging.
+
+## Security model
+
+MeshChatX defaults toward secure local operation:
+
+- HTTPS and WSS enabled by default.
+- Self-signed certificates generated per identity when custom PEM files are absent.
+- Optional HTTP basic authentication (`--auth`).
+- Encrypted session cookies via `aiohttp_session`.
+- CORS, CSP, and defensive middleware on HTTP responses.
+- Access attempt logging with lockout when auth is enabled.
+
+The project includes extensive automated tests around auth and sessions. Even so, exposing MeshChatX directly to the public internet is not recommended without additional hardening.
+
+Password reset is available with `--reset-password` or `MESHCHAT_RESET_PASSWORD=true`, which clears the stored bcrypt hash so you can set a new password in the UI.
+
+## Build and packaging
+
+One source tree produces:
+
+- Development runs via `uv run python -m meshchatx.meshchat`
+- Python wheels with bundled `public/` assets
+- Container images (Dockerfile and hardened variants)
+- Electron builds for Windows, macOS, and Linux
+- Android APK via Chaquopy
+
+Frontend build output always lands in `meshchatx/public/` so runtime behaviour matches across targets.
+
+## Reliability features
+
+- Crash recovery integration in Electron and backend startup checks
+- Database integrity verification
+- Backup, restore, and snapshot APIs
+- Explicit teardown when switching identities or shutting down forwarding resources
+- Health and status endpoints suitable for container probes
+
+## Extensibility
+
+MeshChatX supports plugins with separate frontend and backend runtimes:
+
+- **Contribution registries** under `meshchatx/src/frontend/js/registries/` for navigation, tools, commands, settings, and WebSocket events.
+- **Frontend plugins** run in dedicated Workers (`PluginHost.js`) with declarative UI slots.
+- **Backend plugins** run in wasmtime with fuel metering and capability-gated host functions.
+- **HTTP API** under `/api/v1/plugins/*` for install, enable, invoke, and assets.
+
+Practical extension paths today:
+
+- Plugin manifests with `contributes` and `permissions` blocks
+- New API routes and manager modules
+- Frontend pages wired through registries
+- New settings via `ConfigManager` and CLI or environment variables
+- Database schema changes through migrations
+
+When adding features, prefer identity-scoped state, explicit migrations, endpoint tests, and narrowly declared plugin permissions.
+
+## NomadNet and Mesh Server
+
+The Nomad browser and Mesh Server (page nodes) share a rendering pipeline for Micron, Markdown, plain text, and sanitised HTML. Authoring rules are documented in **NomadNet page formats**.
+
+## Related reading
+
+- **Getting started** for UI navigation and first steps.
+- **LXMF messaging**, **Audio calls**, and **Reticulum interfaces** for feature behaviour.
+- The **Reticulum** tab in Documentation for protocol reference.

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/audio-calls.md b/meshchatx/src/frontend/public/meshchatx-docs/en/audio-calls.md
new file mode 100644
index 00000000..c2ce38ce
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/audio-calls.md
@@ -0,0 +1,87 @@
+# Audio calls (LXST)
+
+MeshChatX uses LXST for voice telephony over Reticulum. Telephone functionality is optional and controlled per identity in settings.
+
+## Enable telephony
+
+Turn on **telephone** in settings before using the **Call** page. MeshChatX announces your callable destination under aspect `lxst.telephony` when announcing is enabled.
+
+Peers who announce the same aspect appear as callable contacts.
+
+## Placing and receiving calls
+
+From **Call** or a contact entry you can:
+
+- **Dial** another identity by hash
+- **Answer** or **decline** inbound rings
+- **Hang up** an active session
+- **Mute** transmit or receive paths
+
+Call state changes arrive over the WebSocket (`telephone_ringing`, `telephone_call_established`, `telephone_call_ended`, and related events).
+
+## Audio path
+
+The frontend loads Codec2 assets for voice encoding (`Codec2Loader.js`). Browser and Electron builds use a Web Audio bridge at `/ws/telephone/audio`. Packaged desktop builds bundle the backend that negotiates LXST sessions.
+
+## Voicemail
+
+When you miss a call, voicemail may be offered depending on settings:
+
+- Record a custom greeting
+- Upload or generate greeting audio
+- Play back messages left for you
+
+Voicemail events surface as `new_voicemail` on the WebSocket.
+
+## Call history and recordings
+
+The **Call** area keeps history of placed, received, and missed calls. You can record calls when the feature is enabled and policy allows storage on your device.
+
+## Ringtones
+
+Upload custom ringtones and assign them per contact. Default sounds are used when no override exists.
+
+## Do not disturb and contacts-only
+
+Settings support:
+
+- **Do not disturb** to silence inbound rings
+- **Contacts-only** mode to reject calls from unknown hashes
+
+Combine these with the **Blocked** list for finer control.
+
+## Telephone contacts
+
+Import and export telephone contacts separately from LXMF conversation peers. Contacts drive caller display names and ringtone overrides.
+
+## Call setup flow
+
+```
+Caller UI: initiate call
+ |
+ v
+GET /api/v1/telephone/call/{identity_hash}
+ |
+ v
+LXST Telephone session over Reticulum
+ |
+ +--> Signalling and media via LXST
+ |
+ +--> /ws/telephone/audio (browser audio bridge)
+ |
+ v
+Callee UI: ring, answer, or decline
+```
+
+## Tips
+
+- Verify **Interfaces** and paths before troubleshooting audio quality. Packet loss on the mesh affects voice.
+- Use headphones on mobile and Quest builds to prevent echo.
+- Review microphone permissions in Electron or the Android system settings if the UI shows no input level.
+- Keep LXST and Reticulum versions aligned with MeshChatX release notes when upgrading.
+
+## See also
+
+- **LXMF messaging** for text conversations with the same peers
+- **Identities, privacy, and security** for HTTPS and local access controls
+- LXST project documentation for codec and session details

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md b/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
new file mode 100644
index 00000000..5c012b56
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/getting-started.md
@@ -0,0 +1,97 @@
+# Getting started with MeshChatX
+
+MeshChatX is a local-first mesh communications client built on the Reticulum Network Stack. It combines direct messaging over LXMF, voice calls over LXST, NomadNet page browsing, relay chat, maps, and a large set of Reticulum utilities in one application you can run on a desktop, a headless server, or a mobile device.
+
+MeshChatX is an independent fork of [Reticulum MeshChat](https://github.com/liamcottle/reticulum-meshchat). It is not affiliated with the upstream project. The website is [meshchatx.com](https://meshchatx.com). Source and releases live on [GitHub](https://github.com/Quad4-Software/MeshChatX).
+
+## What you need to know first
+
+Reticulum is the mesh networking layer. It handles identities, paths, interfaces, and encrypted transport between nodes. LXMF is the messaging protocol MeshChatX uses for conversations, attachments, and propagation. LXST is the telephony layer used for audio calls.
+
+MeshChatX does not replace Reticulum. It runs Reticulum inside a Python process, exposes a web UI, and stores your per-identity data locally in SQLite.
+
+## How the application is laid out
+
+When you open MeshChatX you work inside a single-page web interface. The sidebar lists the main areas of the app. The **Tools** page groups diagnostics and utilities. **Settings** holds per-identity configuration. **Identities** lets you create or switch between separate cryptographic identities.
+
+Typical first-day workflow:
+
+1. Install MeshChatX using a method that fits your device. See **Installation and setup**.
+2. Open the web UI. The default address is `https://127.0.0.1:8000` when HTTPS is enabled.
+3. Go to **Interfaces** and add a way to reach the mesh. A TCP client, community interface suggestion, or LoRa RNode are common starting points.
+4. Wait for paths and announces to populate. Peers appear in the announces list and in feature-specific views.
+5. Open **Messages** to start an LXMF conversation, or **Nomad Network** to browse a page node.
+
+## Runtime shape
+
+MeshChatX ships as one Python service that serves both the API and the built frontend assets.
+
+```
+Browser or Electron window
+ |
+ v
+Vue 3 frontend (hash routes such as #/messages)
+ |
+ | REST under /api/v1/* and WebSocket at /ws
+ v
+meshchatx/meshchat.py (aiohttp server)
+ |
+ +--> SQLite database (per identity)
+ +--> LXMF router and message store
+ +--> LXST telephone (when enabled)
+ +--> Reticulum stack (interfaces, paths, announces)
+```
+
+The same backend code powers Docker images, Python wheels, Linux packages, Electron desktop builds, and the Android APK. Packaging differs. Behaviour is intended to stay consistent.
+
+## Main areas of the UI
+
+| Area | Route | Purpose |
+| ---- | ----- | ------- |
+| Messages | `/messages` | LXMF direct messaging, folders, attachments |
+| Audio calls | `/call` | LXST voice calls and voicemail |
+| Contacts | `/contacts` | Telephone contacts and call-related entries |
+| Relay chat | `/relay-chat` | RRC hubs and rooms (when enabled in settings) |
+| Nomad Network | `/nomadnetwork` | Browse remote NomadNet pages and files |
+| Map | `/map` | OpenLayers map, offline tiles, telemetry |
+| Archives | `/archives` | Versioned snapshots of Nomad pages |
+| Tools | `/tools` | Ping, path tools, RNCP, bots, documentation, and more |
+| Interfaces | `/interfaces` | Add and manage Reticulum interfaces |
+| Network visualiser | `/network-visualiser` | Graph view of mesh topology |
+| Blocked | `/blocked` | Blocked destinations |
+| Settings | `/settings` | Theme, language, LXMF, telephone, security |
+| Identities | `/identities` | Create, import, or switch identities |
+| Documentation | `/documentation` | MeshChatX guides and the Reticulum manual |
+
+Relay chat appears only when `rrc_enabled` is turned on in settings.
+
+## Documentation in the app
+
+The **Documentation** page has two tabs.
+
+**MeshChatX** shows the guides in this bundle. They are markdown files synced from the `docs/` directory in the repository and rendered offline inside the app.
+
+**Reticulum** shows the upstream Reticulum manual as pre-built HTML. It is bundled at build time. You can upload a newer manual ZIP if you need a different version.
+
+Use the search bar to query both sets at once. MeshChatX guide text is currently available in English. The Reticulum manual body is English. Localized landing pages exist for several languages on the Reticulum tab.
+
+## Storage locations
+
+| Data | Typical path |
+| ---- | ------------ |
+| MeshChatX app data | `~/.reticulum-meshchatx/` on Linux and macOS |
+| Reticulum config | `~/.reticulum/` |
+| Per-identity database | `<storage>/identities/<identity_hash>/database.db` |
+| Docker volume | `meshchatx-config` mounted at `/config` |
+
+Legacy upstream data may still exist under `~/.reticulum-meshchat/`. Migration tooling can move you to the MeshChatX layout.
+
+## Where to go next
+
+- **Installation and setup** covers Docker, wheels, desktop packages, and development builds.
+- **Architecture and design** explains backend managers, identity scoping, and the API model.
+- **LXMF messaging** and **Audio calls** describe day-to-day communication features.
+- **Reticulum interfaces** explains how your node joins the mesh.
+- Platform guides under **Platform guides** cover Raspberry Pi, Android Termux, Meta Quest, and Linux sandboxing.
+
+For protocol-level detail, open the **Reticulum** tab in Documentation or visit the [Reticulum manual](https://reticulum.network/manual/) online.

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md b/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md
new file mode 100644
index 00000000..07068a1b
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/identity-and-security.md
@@ -0,0 +1,105 @@
+# Identities, privacy, and security
+
+MeshChatX separates cryptographic identities, network security, and optional privacy controls. This page summarises how they interact.
+
+## Identities
+
+Each identity is a Reticulum key pair with its own:
+
+- SQLite database and LXMF router directory
+- Settings in the `config` table via `ConfigManager`
+- Storage path under `storage/identities/<identity_hash>/`
+
+Create, import, or switch identities from **Identities**. Only one identity is active in the UI at a time. Switching runs a teardown path so routers and managers do not leak state.
+
+Shared resources include the Reticulum process and interface configuration in `~/.reticulum` unless you override paths.
+
+## Announces
+
+MeshChatX tracks announces for aspects such as:
+
+| Aspect | Meaning |
+| ------ | ------- |
+| `lxmf.delivery` | Peer accepts LXMF messages |
+| `lxst.telephony` | Peer accepts LXST calls |
+| `lxmf.propagation` | Propagation node |
+| `nomadnetwork.node` | NomadNet page server |
+| `rrc.hub` | Relay chat hub (when RRC enabled) |
+
+Announce records store signal metadata and parsed app data for display names and icons.
+
+## Web UI authentication
+
+Optional HTTP basic authentication is enabled with `--auth` or `MESHCHAT_AUTH=true`. Sessions use encrypted cookies. Mutating API requests require CSRF tokens.
+
+Access attempts are logged. Repeated failures can trigger lockout when auth is enabled.
+
+Reset a forgotten password with `--reset-password` or `MESHCHAT_RESET_PASSWORD=true`, then set a new password in the UI.
+
+## Transport security
+
+- HTTPS and WSS are on by default.
+- Self-signed certificates are generated per identity when custom PEM files are missing.
+- Pass `--ssl-cert` and `--ssl-key` for managed certificates.
+- Use `--no-https` only on trusted loopback setups.
+
+Electron loads the UI from the local HTTPS origin served by the embedded backend.
+
+## IP allowlisting
+
+`app_security_settings` can restrict which client IPs may use the web UI. Combine with auth when exposing the service beyond localhost.
+
+## Privacy mode
+
+**Privacy mode** blocks outbound HTTP from MeshChatX features that would otherwise call the public internet. Translation and similar tools respect this flag.
+
+Privacy mode does not disable Reticulum mesh traffic. It limits clearnet fetches from the app itself.
+
+## Linux sandboxing
+
+Optional Landlock sandboxing on Linux restricts filesystem access for the backend. See **Linux sandboxing** in Platform guides for Firejail and Bubblewrap examples.
+
+## Blocking and filtering
+
+Use **Blocked** for specific destination hashes. Combine with sieve filters, message blocklists, and LXMF stamp policies described in **LXMF messaging**.
+
+## Data backup
+
+Database backups land in `database-backups/`. Export snapshots from **About** or the API. Electron crash recovery can offer restore when integrity checks fail.
+
+CLI restore example:
+
+```bash
+meshchatx --restore-db /path/to/backup.zip
+```
+
+## Integrity checks
+
+Startup integrity verification runs in packaged Electron builds and can be triggered from the backend. Failed checks surface recovery options instead of silently corrupting data.
+
+## Safe deployment patterns
+
+```
+Recommended for most users
+ |
+ v
+Bind 127.0.0.1, use HTTPS, enable auth if others use the same host
+ |
+ v
+Add interfaces only for meshes you trust
+ |
+ v
+Keep backups and test restore on upgrades
+```
+
+Avoid exposing port 8000 directly to the internet without a reverse proxy, strong auth, and network-level filtering. MeshChatX is designed as a personal or small-team operator console, not a multi-tenant public website.
+
+## Multi-user hosts
+
+On shared computers, use separate OS user accounts or separate `--storage-dir` values so SQLite databases and identity files do not overlap.
+
+## See also
+
+- **Architecture and design** for session and API details
+- **Installation and setup** for CLI security flags
+- Reticulum manual cryptography chapters for identity math

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
new file mode 100644
index 00000000..6b7938d9
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/installation.md
@@ -0,0 +1,153 @@
+# Installation and setup
+
+MeshChatX can be installed in several ways. All release artifacts that ship the web UI include pre-built frontend assets. You do not need Node.js on the machine that only runs the Python wheel or Docker image.
+
+## Requirements
+
+| Component | Version |
+| --------- | ------- |
+| Python | 3.11 or newer (`pyproject.toml`) |
+| Node.js | 24 or newer (development and frontend builds only) |
+| pnpm | 11.1.2 (development) |
+| UV | Used by Taskfile and CI |
+
+**Browsers for the web UI:** Safari 16.4+, Chrome 111+, Firefox 128+.
+
+## Choose an install method
+
+| Method | Frontend included | Best for |
+| ------ | ----------------- | -------- |
+| Docker image | Yes | Fast server setup on Linux |
+| Python wheel | Yes | Headless install without building the UI |
+| Linux AppImage | Yes | Portable desktop on x64 or arm64 |
+| Debian `.deb` | Yes | Debian and Ubuntu systems |
+| RPM package | Yes | Fedora, RHEL, openSUSE style systems |
+| Electron desktop | Yes | Integrated desktop with bundled backend |
+| Android APK | Yes | Phones, tablets, Meta Quest sideload |
+| From source | Built locally | Development and custom builds |
+
+Release images are published to Docker Hub (`quad4io/meshchatx`) and GHCR (`ghcr.io/quad4-software/meshchatx`).
+
+## Docker
+
+Quick start with Compose:
+
+```bash
+docker compose up -d
+```
+
+Manual run with a named volume for persistence:
+
+```bash
+docker run -d --name reticulum-meshchatx \
+ --restart unless-stopped \
+ --security-opt no-new-privileges:true \
+ -p 127.0.0.1:8000:8000 \
+ -v meshchatx-config:/config \
+ ghcr.io/quad4-software/meshchatx:latest
+```
+
+Default Compose maps `127.0.0.1:8000` on the host to port `8000` in the container. Data persists in the `meshchatx-config` volume at `/config`.
+
+To bind a host directory instead, mount it at `/config`. The container runs as UID 1000. The host directory must be writable by that user.
+
+## Python wheel
+
+1. Download `reticulum_meshchatx-*-py3-none-any.whl` from [releases](https://github.com/Quad4-Software/MeshChatX/releases).
+2. Install with pip, pipx, or uv:
+
+```bash
+pip install reticulum_meshchatx-*.whl
+```
+
+3. Start the server:
+
+```bash
+meshchatx --headless --host 127.0.0.1
+```
+
+The `meshchat` command is a compatibility alias for the same entry point.
+
+## Linux AppImage and packages
+
+**AppImage**
+
+```bash
+chmod +x ./ReticulumMeshChatX-v*-linux-*.AppImage
+./ReticulumMeshChatX-v*-linux-*.AppImage
+```
+
+**Debian package**
+
+```bash
+sudo dpkg -i reticulum-meshchatx_*_amd64.deb
+```
+
+Adjust the filename for your architecture.
+
+## From source (development)
+
+```bash
+task install
+pnpm run build-frontend
+uv run python -m meshchatx.meshchat --headless --host 127.0.0.1
+```
+
+Useful task targets include `task format`, `task lint`, `task test`, and `task build`.
+
+## First launch
+
+On first run MeshChatX creates a random Reticulum identity if you do not pass one on the command line. The identity file is stored under your configured storage directory.
+
+Open the UI at the host and port you chose. HTTPS is enabled by default with a self-signed certificate unless you pass `--no-https` or provide your own PEM files.
+
+## Command-line options
+
+Common flags and environment variables:
+
+| Flag | Environment variable | Default | Description |
+| ---- | -------------------- | ------- | ----------- |
+| `--host` | `MESHCHAT_HOST` | `127.0.0.1` | Bind address |
+| `--port` | `MESHCHAT_PORT` | `8000` | HTTP or HTTPS port |
+| `--no-https` | `MESHCHAT_NO_HTTPS` | false | Serve plain HTTP |
+| `--ssl-cert` | `MESHCHAT_SSL_CERT` | auto | TLS certificate path |
+| `--ssl-key` | `MESHCHAT_SSL_KEY` | auto | TLS private key path |
+| `--headless` | `MESHCHAT_HEADLESS` | false | Do not open a browser |
+| `--auth` | `MESHCHAT_AUTH` | false | Require HTTP basic auth for the UI |
+| `--storage-dir` | `MESHCHAT_STORAGE_DIR` | `./storage` | Application data directory |
+| `--reticulum-config-dir` | (see `--help`) | `~/.reticulum` | Reticulum configuration |
+| `--identity-file` | `MESHCHAT_IDENTITY_FILE` | none | Load identity from file |
+| `--rns-log-level` | `MESHCHAT_RNS_LOG_LEVEL` | none | Reticulum log level |
+| `--auto-recover` | `MESHCHAT_AUTO_RECOVER` | false | Attempt SQLite recovery on start |
+| `--emergency` | | false | Start without database |
+| `--disable-plugins` | | false | Disable the plugin system |
+
+CLI flags override environment variables when both are set.
+
+## Reticulum manual bundle
+
+The Reticulum HTML manual is fetched at build time. After cloning the repository, run:
+
+```bash
+pnpm run build-docs
+```
+
+This populates `meshchatx/public/reticulum-docs-bundled/current/`. Without that step the Reticulum tab may show an upload prompt until you build docs or upload a manual ZIP.
+
+## Identity bootstrap
+
+You can supply an identity at startup:
+
+- `--identity-file /path/to/identity`
+- `--identity-base64` or `--identity-base32` with the corresponding environment variables
+
+Otherwise MeshChatX generates one and saves it under `<storage>/identity`. Additional identities are created from the **Identities** page. Each identity has its own database, LXMF router, and settings while sharing one Reticulum process.
+
+## After install
+
+1. Add at least one **interface** so Reticulum can reach peers.
+2. Review **Settings** for display name, theme, language, and LXMF stamp costs.
+3. Enable **telephone** in settings if you plan to use audio calls.
+4. Open **Documentation** for MeshChatX guides and the Reticulum manual offline.
+
+Platform-specific notes live under **Platform guides** in this documentation bundle.

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/interfaces.md b/meshchatx/src/frontend/public/meshchatx-docs/en/interfaces.md
new file mode 100644
index 00000000..81a47461
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/interfaces.md
@@ -0,0 +1,87 @@
+# Reticulum interfaces
+
+Interfaces connect your MeshChatX node to the Reticulum mesh. Manage them from the **Interfaces** page.
+
+## What an interface does
+
+Each interface is a Reticulum transport definition. Examples include TCP over the internet, UDP discovery, LoRa through an RNode, serial KISS devices, I2P tunnels, and automatic LAN discovery.
+
+MeshChatX reads and writes interface configuration in your Reticulum config directory (default `~/.reticulum`).
+
+## Supported interface types
+
+The **Add interface** flow includes:
+
+| Type | Typical use |
+| ---- | ----------- |
+| TCPClientInterface | Connect outbound to a known TCP peer |
+| TCPServerInterface | Accept inbound TCP connections |
+| BackboneInterface | High-throughput backbone link |
+| UDPInterface | UDP transport with discovery helpers |
+| RNodeInterface | LoRa via RNode (serial, BLE, or IP transport) |
+| RNodeIPInterface | RNode reached over IP |
+| SerialInterface | Direct serial devices |
+| KISSInterface | KISS TNC devices |
+| I2PInterface | I2P-based Reticulum transport |
+| AutoInterface | Automatic discovery on local networks |
+| Custom external types | Advanced setups |
+
+Community-curated suggestions come from `community_interfaces.json`, sourced from [directory.rns.recipes](https://directory.rns.recipes).
+
+## Interface discovery
+
+Discovery can automatically connect to peers on your LAN or configured networks. You can maintain allowlists and blocklists, set autoconnect behaviour, and assign a network identity for discovered peers.
+
+## Import and export
+
+Export your interface set for backup or clone it to another machine. Import validates entries before applying them.
+
+## RNode tools
+
+LoRa setups often need firmware management. **Tools → RNode Flasher** opens the bundled flasher at `/rnode-flasher/`. Configure frequency, bandwidth, spreading factor, and TX power when adding an RNode interface.
+
+## Websocket server interface
+
+MeshChatX includes a custom `WebsocketServerInterface` for WebSocket-based Reticulum transport. Use it when bridging to web-friendly gateways.
+
+## Getting onto the mesh
+
+A minimal path for a new node:
+
+```
+Install MeshChatX
+ |
+ v
+Add interface (TCP client, community suggestion, or RNode)
+ |
+ v
+Reticulum establishes transport
+ |
+ v
+Paths and announces populate in the UI
+ |
+ v
+LXMF, LXST, and Nomad features become reachable
+```
+
+1. Pick a community interface or ask your mesh operator for TCP endpoint details.
+2. Add the interface and enable it.
+3. Watch the path table (**Tools → RNPath**) if connectivity fails.
+4. Enable **auto-announce** so your services are visible.
+
+## Bundled documentation hints
+
+The Interfaces UI links into the Reticulum manual sections on interface options. Open **Documentation → Reticulum** and search for `interfaces` if you need field-by-field reference.
+
+## Tips
+
+- Run only the interfaces you need. Each open port or radio adds attack surface and power draw.
+- On Raspberry Pi and Android, prefer a single well-known TCP uplink if LoRa hardware is not attached.
+- After editing Reticulum config externally, use the reload controls or restart MeshChatX so changes apply cleanly.
+- Keep firmware on RNodes current using the flasher tool before debugging RF issues.
+
+## See also
+
+- **Installation and setup** for Reticulum config directory flags
+- **Tools and utilities** for RNPath, RNProbe, and Ping
+- Reticulum manual **Interfaces** chapter for protocol-level detail

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md b/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md
new file mode 100644
index 00000000..d54688de
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md
@@ -0,0 +1,111 @@
+# LXMF messaging
+
+MeshChatX uses LXMF (LXMF Message Format) for direct and store-and-forward messaging over Reticulum. Each identity has an `LXMRouter` registered under aspect `lxmf.delivery`.
+
+## Conversations
+
+Open **Messages** to see your conversation list. Each row is a peer destination you have exchanged traffic with or selected from announces.
+
+From a conversation you can:
+
+- Send and receive text messages
+- Attach images, audio clips, and files
+- Reply with quotes and add reactions
+- Organise threads into folders and pin important chats
+- Run bulk operations on multiple conversations
+
+Incoming messages arrive over the WebSocket as `lxmf_message` events. The UI updates without a full page reload.
+
+## Attachments and rich content
+
+The composer supports:
+
+- **Images** via LXMF image fields
+- **Audio** via LXMF audio fields
+- **Files** as LXMF file attachments
+- **Stickers and GIFs** when enabled in settings
+- **User icons** stored as LXMF app data
+
+Large payloads follow LXMF sizing and stamp rules configured in settings.
+
+## Propagation nodes
+
+When a peer is not reachable directly, LXMF can store messages on propagation nodes.
+
+MeshChatX can:
+
+- Run a **local propagation node** on your identity
+- **Sync** with remote propagation nodes you trust
+- **Auto-select** a preferred node via `AutoPropagationManager`
+- **Retry** failed direct deliveries through propagation when configured
+
+Manage nodes from **Tools → Propagation nodes** or related settings entries.
+
+## Stamp costs and stranger protection
+
+LXMF uses work proofs (stamps) to limit abuse. Settings let you tune:
+
+- Outbound stamp costs for your messages
+- Inbound stamp requirements for unknown senders
+- **Stranger protection** options such as blocking strangers, attachments, or links from unknown peers
+- **Flood protection** with dynamic inbound stamp costs based on rate
+
+Raise inbound costs when you operate a public-facing node. Lower them on trusted private meshes.
+
+## Filtering and blocking
+
+- **Blocked** destinations stop traffic from specific hashes.
+- **Sieve filters** (beta) drop inbound messages by pattern.
+- **Message blocklist** (beta) complements sieve rules for known bad content.
+- **Spam reporting** helps you mark unwanted conversations.
+
+## Paper messages
+
+**Tools → Paper message** generates LXMF URIs you can share as QR codes. Another MeshChatX user can ingest the URI to receive the payload. Useful for offline handoff when no live path exists yet.
+
+## Forwarding
+
+`ForwardingManager` supports alias identities that forward messages between peers according to rules you define. Configure forwarding from **Tools → Forwarder**.
+
+## Import and export
+
+You can import and export messages and folder structures for backup or migration. Operations go through the API and respect identity boundaries.
+
+## Local retention
+
+**Local message auto-delete** removes old messages after a configured retention period. Tune this in settings if you operate on storage-constrained hardware.
+
+## Messaging flow
+
+```
+Composer in UI
+ |
+ v
+POST /api/v1/lxmf-messages/send
+ |
+ v
+LXMRouter (identity-local)
+ |
+ +--> Direct path to peer destination
+ |
+ +--> Propagation node (when direct delivery fails or policy requires it)
+ |
+ v
+Peer LXMF router
+ |
+ v
+WebSocket lxmf_message event on recipient UI
+```
+
+## Tips
+
+- Set a **display name** in settings so announces show a friendly label.
+- Enable **auto-announce** so your `lxmf.delivery` aspect stays visible on the mesh.
+- Check **Interfaces** if messages stall. No path to the peer means LXMF cannot deliver.
+- Review stamp settings before joining busy public meshes.
+
+## See also
+
+- **Reticulum interfaces** for connectivity
+- **Identities, privacy, and security** for auth and HTTPS
+- Reticulum manual section on LXMF for protocol detail

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/nomad-network.md b/meshchatx/src/frontend/public/meshchatx-docs/en/nomad-network.md
new file mode 100644
index 00000000..68cb88d8
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/nomad-network.md
@@ -0,0 +1,77 @@
+# Nomad Network and Mesh Server
+
+Nomad Network is a distributed page and file system on top of Reticulum. MeshChatX includes a browser for remote nodes and a **Mesh Server** tool for hosting your own pages.
+
+## Nomad browser
+
+Open **Nomad Network** and enter a node destination hash. MeshChatX fetches the default entry page (usually `/page/index.mu`) over Reticulum link requests.
+
+Supported page types:
+
+| Extension | Format |
+| --------- | ------ |
+| `.mu` | Micron markup (NomadNet default) |
+| `.md` | Markdown with GFM-oriented rendering |
+| `.txt` | Plain text with preserved whitespace |
+| `.html` | Static HTML with sanitised CSS |
+
+Follow links inside pages to browse further paths on the same node. Download files offered at `/file/*` paths.
+
+Rendering uses `NomadPageRenderer.js` with DOMPurify sanitization. Micron can use a JavaScript parser or optional Go WASM when `nomad_micron_wasm_enabled` is set.
+
+## Favourites and caching
+
+Save frequent nodes as favourites. Link caching (`nomadnet_cached_links`) speeds up repeat visits on slow links.
+
+## Archives
+
+When **page archiver** is enabled, MeshChatX stores versioned snapshots of pages you visit. Open **Archives** to browse historical copies. An optional crawler can archive automatically.
+
+Archived pages use the same renderer as the live browser based on the stored `page_path` extension.
+
+## Mesh Server (page nodes)
+
+**Tools → Mesh Server** lets you run a `nomadnetwork.node` destination locally.
+
+Typical workflow:
+
+1. Create a page node in the UI.
+2. Upload `.mu`, `.md`, `.txt`, or `.html` pages and optional files.
+3. Start the node and announce it on the mesh.
+4. Share your destination hash so others can open `/page/index.mu` on your node.
+
+API endpoints under `/api/v1/page-nodes/` manage CRUD operations, start and stop, and file listings.
+
+Pages are served at `/page/<name>` and files at `/file/<name>` on the node destination.
+
+## Browsing flow
+
+```
+User enters destination hash
+ |
+ v
+RNS link request to /page/index.mu (or chosen path)
+ |
+ v
+Remote page node responds with content
+ |
+ v
+NomadPageRenderer picks Micron, Markdown, text, or HTML pipeline
+ |
+ v
+Sanitised HTML shown in Nomad Network view
+```
+
+## Authoring pages
+
+Read **NomadNet page formats** for security rules, Markdown quirks, and API behaviour. The Mesh Server rejects disallowed extensions on upload.
+
+## Micron editor
+
+**Tools → Micron editor** helps author `.mu` pages before you upload them to your node.
+
+## See also
+
+- **NomadNet page formats** for detailed authoring reference
+- **Tools and utilities** for the full tools list
+- **Reticulum interfaces** if remote pages time out (likely a path issue)

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/nomadmesh-pages.md b/meshchatx/src/frontend/public/meshchatx-docs/en/nomadmesh-pages.md
new file mode 100644
index 00000000..c50f76cc
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/nomadmesh-pages.md
@@ -0,0 +1,52 @@
+# NomadNet page formats
+
+MeshChatX serves pages from a **Mesh Server** page node and displays them in the **Nomad Network** browser. Pages are fetched with the Nomad path convention `/page/<filename>`.
+
+## Supported filenames
+
+| Extension | Role |
+| --------- | ---- |
+| `.mu` | Micron markup (NomadNet default) |
+| `.md` | Markdown with GitHub-flavored features via the renderer |
+| `.txt` | Plain text with escaped HTML and preserved whitespace |
+| `.html` | Static HTML with CSS only (see security below) |
+
+If you add a page without a recognised extension, the server stores it as `.mu`. Filenames with other extensions (for example `.exe`) are rejected when saving through the API.
+
+## Plain text (`.txt`)
+
+Content is HTML-escaped and shown with pre-wrapped whitespace. There is no Markdown parsing on `.txt` pages.
+
+## Markdown (`.md`)
+
+**Not the same engine as chat.** Conversations use the lightweight `MarkdownRenderer` in the messaging UI. Nomad `.md` pages use `marked` with GFM-oriented rules plus sanitisation. Features and edge cases can differ between the two paths. Automated tests cover both.
+
+Authoring tips:
+
+- Use ATX headings with a hash and a space before the title, for example `# Title`, `## Section`, `#### Subsection`.
+- Fenced code blocks keep indentation.
+- Off-mesh `http` and `https` links in rendered content are removed or restricted so the preview cannot drive external navigation without mesh-style URLs.
+
+## HTML (`.html`)
+
+- **JavaScript** is not executed. `script` tags and event-handler attributes are stripped.
+- **External resources** are blocked where possible. `@import` and `url(...)` pointing at `http://`, `https://`, or protocol-relative URLs are removed from CSS.
+- Embedded `<style>` blocks are kept. Rules that target `html` or `body` are rewritten to apply to the viewer root container.
+- **Links** must be mesh-style (`:` paths, 32-character hex prefixes, `/page/...`, `/file/...`, or `#` fragments) or they are removed.
+- **Images** only keep `data:image/...` inline sources.
+- The viewer uses a sans-serif font for HTML and Markdown so pages do not inherit Micron monospace chrome. Override colours and typography with your own CSS.
+
+## Mesh Server API
+
+- `POST /api/v1/page-nodes/{node_id}/pages` with `name` and `content` saves a page. Invalid extensions return HTTP 400 with a short message.
+- Listed pages only include files with allowed extensions in the `pages/` directory.
+
+## Archives
+
+Snapshots in **Archives** use the same rendering pipeline as the Nomad browser. The archived `page_path` extension selects Micron, Markdown, text, or HTML handling. Exports keep the original extension when it is `.mu`, `.md`, `.txt`, or `.html`.
+
+## See also
+
+- **Nomad Network and Mesh Server** for browsing and hosting workflows
+- **Architecture and design** for where page nodes fit in the backend
+- Default Nomad entry path remains `/page/index.mu` unless you change the URL in the browser

diff --git a/docs/meshchatx_on_android_with_termux.md b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/android-termux.md
similarity index 98%
rename from docs/meshchatx_on_android_with_termux.md
rename to meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/android-termux.md
index 9048d00e..9c8c2908 100644
--- a/docs/meshchatx_on_android_with_termux.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/android-termux.md
@@ -1,4 +1,4 @@
-# MeshChatX on Android
+# Android with Termux
It's possible to run MeshChatX on Android using [Termux](https://termux.dev/). Installation is now much simpler since the wheel package includes both the server and pre-built web assets.

diff --git a/docs/meshchatx_linux_sandbox.md b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/linux-sandbox.md
similarity index 99%
rename from docs/meshchatx_linux_sandbox.md
rename to meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/linux-sandbox.md
index 1a7b03dc..812f691b 100644
--- a/docs/meshchatx_linux_sandbox.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/linux-sandbox.md
@@ -1,4 +1,4 @@
-# MeshChatX on Linux: Firejail and Bubblewrap
+# Linux sandboxing with Firejail and Bubblewrap
This page shows how to run **`meshchatx`** under **Firejail** or **Bubblewrap** (`bwrap`) on Linux. The legacy CLI name **`meshchat`** installs the same entry point and can be substituted in these examples. Use this when you install MeshChatX natively (wheel, package, or Poetry) and want an extra layer of filesystem and process isolation compared to running the binary directly.

diff --git a/docs/meshchatx_on_quest_with_sidequest.md b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/quest-sidequest.md
similarity index 91%
rename from docs/meshchatx_on_quest_with_sidequest.md
rename to meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/quest-sidequest.md
index 33517894..7d7bb7d5 100644
--- a/docs/meshchatx_on_quest_with_sidequest.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/quest-sidequest.md
@@ -1,11 +1,9 @@
-# MeshChatX on Meta Quest (Quest 2 and newer)
+# Meta Quest with SideQuest
The MeshChatX Android APK runs on Meta Quest 2, Quest 3, Quest 3S, and Quest Pro. Quest headsets run a modified Android runtime, so the same universal APK published for phones and tablets can be installed by sideloading.
MeshChatX opens as a **2D panel** inside your VR environment. It is not a native VR application. You get the full MeshChatX web UI in a floating window while you remain in your Quest home space.
-![MeshChatX running on Meta Quest 2](../screenshots/vr/meshchatx-quest2.jpeg)
-
## What you need
- A Meta Quest 2 or newer headset

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_raspberry_pi.md b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/raspberry-pi.md
similarity index 99%
rename from meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_raspberry_pi.md
rename to meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/raspberry-pi.md
index bd118a4c..a02651b8 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_raspberry_pi.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/platform-guides/raspberry-pi.md
@@ -1,4 +1,4 @@
-# MeshChatX on Raspberry Pi
+# Raspberry Pi headless setup
This guide shows a simple headless setup for running MeshChatX on a Raspberry Pi 4
with a web UI you can access from another device on your network.

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
new file mode 100644
index 00000000..ac9ec3ac
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/tools.md
@@ -0,0 +1,105 @@
+# Tools and utilities
+
+The **Tools** page groups mesh diagnostics and helper apps. Each tool opens its own view with a back link to the grid.
+
+## Network diagnostics
+
+| Tool | Purpose |
+| ---- | ------- |
+| Ping | Measure round-trip time to a reachable destination |
+| RNProbe | Probe whether a destination answers |
+| RNPath | Inspect the path table |
+| RNPath-trace | Trace hops toward a destination |
+| RNStatus | Read node status information |
+| Network visualiser | Graph view of topology (also in main navigation) |
+
+Use these when messages or pages fail despite interfaces showing as enabled.
+
+## File transfer and shell
+
+| Tool | Purpose |
+| ---- | ------- |
+| RNCP | Send or fetch files over Reticulum |
+| RNSH | Remote shell sessions with streamed output |
+
+RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
+
+## Messaging helpers
+
+| Tool | Purpose |
+| ---- | ------- |
+| Propagation nodes | Manage LXMF propagation nodes and sync |
+| Forwarder | Configure LXMF forwarding rules between aliases |
+| Sieve filters | Pattern-based inbound message filtering (beta) |
+| Message blocklist | Block known unwanted content (beta) |
+| Paper message | Create or ingest LXMF URIs and QR workflows |
+| Bots | Run subprocess LXMF bots from templates |
+
+Bot templates include echo, note, and reminder starters. They use the bundled `lxmfy` package.
+
+## Content and publishing
+
+| Tool | Purpose |
+| ---- | ------- |
+| Mesh Server | Host NomadNet-compatible page nodes |
+| Micron editor | Edit `.mu` pages locally |
+| Documentation | MeshChatX guides and Reticulum manual |
+
+## Configuration editors
+
+| Tool | Purpose |
+| ---- | ------- |
+| Reticulum config editor | Edit raw Reticulum configuration |
+| Repository server | Host Python wheels for offline installs |
+
+## Hardware and translation
+
+| Tool | Purpose |
+| ---- | ------- |
+| RNode flasher | Flash or update RNode firmware |
+| Translator | Translate text via Argos Translate or LibreTranslate |
+
+Translator calls respect **privacy mode**. When privacy mode blocks outbound HTTP, external translation endpoints are not contacted.
+
+## Debugging
+
+| Tool | Purpose |
+| ---- | ------- |
+| Debug logs | View backend debug log stream |
+
+## Coming soon
+
+The registry marks **RNS Tunnel** and **RNS FileSync** as coming soon. They do not have routes in the current release.
+
+## Relay chat server
+
+When `rrc_enabled` is on, you can run a local RRC hub from relay chat server settings. Hubs announce aspect `rrc.hub`. Client UI lives under **Relay chat** in the main navigation.
+
+## Plugins
+
+Installed plugins can add rows to **Tools** and **Navigation** through contribution manifests. Example bundled plugin: **Mesh Observatory** (`com.meshchatx.mesh-observatory`) for live announce feeds and path tables.
+
+Disable plugins at startup with `--disable-plugins` if you need a minimal surface.
+
+## Command palette
+
+Press the command palette shortcut (configured in settings) to jump to tools and pages without returning to the grid.
+
+## Choosing a tool
+
+```
+Symptom Tool to try first
+-------------------------------- -----------------
+No peers visible Interfaces, then RNPath
+Message stuck sending RNPath, Propagation nodes
+Cannot reach Nomad page Ping, RNProbe
+Need to push a file RNCP
+Remote administration RNSH (with care)
+Want offline Python packages Repository server
+```
+
+## See also
+
+- **Reticulum interfaces** for transport setup
+- **LXMF messaging** and **Nomad Network** for feature-specific workflows
+- **Documentation** for offline manuals

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/manifest.json b/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
new file mode 100644
index 00000000..73a7e288
--- /dev/null
+++ b/meshchatx/src/frontend/public/meshchatx-docs/manifest.json
@@ -0,0 +1,107 @@
+{
+ "version": 1,
+ "default_language": "en",
+ "languages": [
+ { "code": "en", "name": "English" }
+ ],
+ "sections": [
+ {
+ "id": "overview",
+ "order": 1,
+ "title": { "en": "Overview" },
+ "items": [
+ {
+ "path": "en/getting-started.md",
+ "lang": "en",
+ "title": { "en": "Getting started" }
+ },
+ {
+ "path": "en/installation.md",
+ "lang": "en",
+ "title": { "en": "Installation and setup" }
+ },
+ {
+ "path": "en/architecture.md",
+ "lang": "en",
+ "title": { "en": "Architecture and design" }
+ }
+ ]
+ },
+ {
+ "id": "features",
+ "order": 2,
+ "title": { "en": "Features" },
+ "items": [
+ {
+ "path": "en/messaging.md",
+ "lang": "en",
+ "title": { "en": "LXMF messaging" }
+ },
+ {
+ "path": "en/audio-calls.md",
+ "lang": "en",
+ "title": { "en": "Audio calls (LXST)" }
+ },
+ {
+ "path": "en/nomad-network.md",
+ "lang": "en",
+ "title": { "en": "Nomad Network and Mesh Server" }
+ },
+ {
+ "path": "en/interfaces.md",
+ "lang": "en",
+ "title": { "en": "Reticulum interfaces" }
+ },
+ {
+ "path": "en/tools.md",
+ "lang": "en",
+ "title": { "en": "Tools and utilities" }
+ },
+ {
+ "path": "en/identity-and-security.md",
+ "lang": "en",
+ "title": { "en": "Identities, privacy, and security" }
+ }
+ ]
+ },
+ {
+ "id": "authoring",
+ "order": 3,
+ "title": { "en": "Authoring" },
+ "items": [
+ {
+ "path": "en/nomadmesh-pages.md",
+ "lang": "en",
+ "title": { "en": "NomadNet page formats" }
+ }
+ ]
+ },
+ {
+ "id": "platforms",
+ "order": 4,
+ "title": { "en": "Platform guides" },
+ "items": [
+ {
+ "path": "en/platform-guides/raspberry-pi.md",
+ "lang": "en",
+ "title": { "en": "Raspberry Pi" }
+ },
+ {
+ "path": "en/platform-guides/android-termux.md",
+ "lang": "en",
+ "title": { "en": "Android (Termux)" }
+ },
+ {
+ "path": "en/platform-guides/quest-sidequest.md",
+ "lang": "en",
+ "title": { "en": "Meta Quest (SideQuest)" }
+ },
+ {
+ "path": "en/platform-guides/linux-sandbox.md",
+ "lang": "en",
+ "title": { "en": "Linux sandboxing" }
+ }
+ ]
+ }
+ ]
+}

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx.md b/meshchatx/src/frontend/public/meshchatx-docs/meshchatx.md
deleted file mode 100644
index 0628cdf2..00000000
--- a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx.md
+++ /dev/null
@@ -1,174 +0,0 @@
-# MeshChatX Architecture and Design
-
-MeshChatX is a very heavily customized fork of Reticulum-Meshchat, it is vastly different under the hood.
-
-## Goals and Constraints
-
-- Keep a local-first runtime model that works on desktop and headless systems.
-- Preserve Reticulum and LXMF semantics while improving UX and operational tooling.
-- Support multi-identity usage in one runtime without cross-identity data leakage.
-- Keep the backend and frontend independently testable.
-- Run in constrained environments (single board devices, containers, AppImage/desktop).
-
-## System Overview
-
-At a high level, MeshChatX is a single-process Python service that:
-
-- initializes identity-specific context and persistent state,
-- exposes HTTP API and WebSocket endpoints for the frontend,
-- serves the built frontend assets from a local public directory,
-- manages LXMF/Reticulum interactions and higher-level features.
-
-The frontend is a SPA built with Vite and mounted in the same runtime context as the API.
-
-## Runtime Topology
-
-### Backend Runtime
-
-- Main entrypoint: `meshchatx/meshchat.py` (orchestration). Shared helpers live in `meshchatx/src/path_utils.py`, `meshchatx/src/ssl_self_signed.py`, and `meshchatx/src/env_utils.py`; `meshchat.py` re-exports them for compatibility.
-- Web stack: `aiohttp` + `aiohttp_session`
-- Realtime channel: WebSocket endpoints for UI updates and control flows
-- Transport/security: HTTPS by default, optional HTTP, optional custom cert paths
-
-### Frontend Runtime
-
-- Source tree: `meshchatx/src/frontend`
-- Build output: `meshchatx/public`
-- Served by backend static routing
-- Uses API + WebSocket for state hydration and live updates
-
-### Optional Desktop Runtime
-
-- Electron packaging/build scripts at repository root
-- Backend binaries/resources are bundled for packaged desktop artifacts
-
-## Core Backend Design
-
-### 1) Application Shell
-
-`ReticulumMeshChat` in `meshchatx/meshchat.py` is the orchestration layer. It owns:
-
-- server lifecycle,
-- route registration,
-- identity context switching and teardown,
-- shared process-level concerns (logging, crash recovery wiring, health checks).
-
-It intentionally centralizes operational control so runtime state changes happen in a predictable order.
-
-### 2) Identity-Scoped Context Model
-
-`IdentityContext` in `meshchatx/src/backend/identity_context.py` encapsulates state for one identity:
-
-- storage path rooted at `storage/identities/<identity_hash>/`,
-- identity-local SQLite DB,
-- identity-local LXMF router state,
-- manager instances (messages, announces, docs, map, forwarding, tools, and more).
-
-This boundary prevents accidental cross-identity writes and keeps teardown deterministic.
-
-### 3) Manager-Centric Domain Logic
-
-Feature logic is delegated to dedicated backend modules under `meshchatx/src/backend`:
-
-- message handling and routing,
-- announce management and trimming/limits,
-- docs, maps, page nodes, telemetry, interfaces,
-- forwarding aliases and propagation synchronization,
-- utility handlers for RN-specific tooling.
-
-The design intent is to keep transport/runtime orchestration in `meshchat.py` and business/domain behavior in dedicated managers. Optional **RNS log level** is configured with **`--rns-log-level`** or **`MESHCHAT_RNS_LOG_LEVEL`** (CLI overrides env when both are set).
-
-### 4) Persistence Layer
-
-- Storage engine: SQLite
-- Access style: explicit SQL-oriented data access layer (no heavyweight ORM)
-- Schema migration and integrity checks are integrated into startup and context setup.
-
-The project favors predictable SQL behavior and explicit migration control, which helps with compatibility and debugging on diverse platforms.
-
-## API and Realtime Design
-
-### HTTP API
-
-- Implemented as explicit `aiohttp` routes in `meshchat.py`
-- Includes app status, auth, messaging, interfaces, docs/tools, and maintenance endpoints
-- Static assets are served from the frontend build output directory
-
-### WebSockets
-
-- Used for low-latency frontend state updates
-- Keeps UI responsive for message state transitions and live network events
-
-### Session/Auth Flow
-
-- Cookie sessions via encrypted storage
-- Auth and access-attempt tracking integrated with IP/User-Agent aware controls
-- Debug endpoints provide visibility into logs and access-attempt records
-- Password reset via `--reset-password` (or `MESHCHAT_RESET_PASSWORD=true`) clears the stored bcrypt hash so a new password can be set through the web UI
-
-This is also very well tested, but I still would not recommend exposing MeshChatX to the internet.
-
-## Security Model
-
-MeshChatX defaults toward secure local operation:
-
-- HTTPS/WSS enabled by default.
-- Self-signed cert generation if identity-local cert files are absent.
-- Optional custom cert/key pair when deployment needs managed TLS material.
-- CORS and CSP
-- Session encryption and defensive middleware.
-- Access attempt persistence plus lockout/rate limiting strategy (when auth enabled).
-
-Since its HTTPS/WSS other local apps cannot sniff the traffic as easily.
-
-## Build and Packaging Strategy
-
-MeshChatX supports multiple deployment forms from one source tree:
-
-- source/development execution,
-- Python package and wheel distribution,
-- container images,
-- Electron desktop builds for major platforms.
-
-The design uses a shared backend codebase and frontend build artifacts so feature behavior remains consistent across packaging targets.
-
-## Operations and Reliability
-
-Reliability features include:
-
-- crash recovery integration,
-- startup integrity/database health checks,
-- backup/restore and snapshot support,
-- explicit teardown flows for multi-context and forwarding resources,
-- status endpoint for orchestration and container probes.
-
-## NomadNet pages and Mesh Server
-
-The built-in **NomadNet** browser and **Mesh Server** (page nodes) support Micron (`.mu`), Markdown (`.md`), plain text (`.txt`), and sanitised static HTML (`.html`). Pages are registered under `/page/<name>` on each node’s destination.
-
-Authoring rules, security constraints for HTML/CSS, and API behaviour are documented in **`nomadmesh_pages.md`** in the same docs bundle (also available under **Documentation** in the app when MeshChatX docs are populated).
-
-## Extensibility Points
-
-MeshChatX supports a capability-based plugin system with separate frontend and backend runtimes:
-
-- **Contribution registries** under `meshchatx/src/frontend/js/registries/` for sidebar navigation, tools, command palette actions, settings sections, and typed WebSocket events.
-- **Frontend plugins** run in dedicated Workers (`meshchatx/src/frontend/js/plugins/PluginHost.js`) with declarative UI slots rendered by `PluginSlotRenderer.vue`.
-- **Backend plugins** run in wasmtime with fuel metering and capability-gated host functions (`meshchatx/src/backend/plugin_manager.py`).
-- **Generic plugin API** under `/api/v1/plugins/*` for install, enable/disable, invoke, and asset serving.
-
-The most practical extension points today are:
-
-- plugin manifests in `plugin.json` with `contributes` and `permissions` blocks,
-- new API routes in backend routing sections,
-- new manager modules under `meshchatx/src/backend`,
-- frontend page/component additions wired through contribution registries,
-- new config surface through CLI flags + environment variables,
-- schema extension through the existing migration/versioning approach.
-
-When adding features, prefer:
-
-- identity-scoped state over global mutable state,
-- explicit migration/version changes for DB schema updates,
-- endpoint-level tests plus focused manager unit tests,
-- plugin permissions that are declared in manifests and enforced by the host.

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/nomadmesh_pages.md b/meshchatx/src/frontend/public/meshchatx-docs/nomadmesh_pages.md
deleted file mode 100644
index bde3cfc4..00000000
--- a/meshchatx/src/frontend/public/meshchatx-docs/nomadmesh_pages.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# NomadNet Network browser and Mesh Server pages
-
-MeshChatX can serve pages from a **Mesh Server** (local Reticulum page node) found in the tools section and display them in the **NomadNet** browser. Pages are fetched over the usual Nomad path convention: `/page/<filename>`.
-
-## Supported filenames
-
-| Extension | Role |
-| --------- | ------------------------------------------------------------ |
-| `.mu` | **Micron** markup (NomadNet default). |
-| `.md` | **Markdown** (GitHub-flavored features via the renderer). |
-| `.txt` | **Plain text** (shown escaped, monospace-friendly wrapping). |
-| `.html` | **Static HTML** with **CSS only** (see security below). |
-
-If you add a page without a recognised extension, the server stores it as **`.mu`**. Filenames with other extensions (for example `.exe`) are rejected when saving through the API.
-
-## Plain text (`.txt`)
-
-Content is **HTML-escaped** and shown with **pre-wrapped** whitespace. There is no Markdown parsing on `.txt` pages.
-
-## Markdown (`.md`)
-
-- **Not the same engine as chat:** Conversations use the lightweight **`MarkdownRenderer`** (HTML-escaped first, then patterns for headers, bold, code, links). Nomad **`.md`** pages use **`marked`** (GFM-oriented) plus sanitisation, so features and edge cases can differ. Automated tests cover both paths.
-- Use **ATX headings** with a hash and a **space** before the title, for example `# Title`, `## Section`, `#### Subsection`. CommonMark requires that space; MeshChatX may normalise some common shorthand forms, but relying on the standard form is safest.
-- Line breaks and spacing: the viewer preserves wrapping behaviour suitable for technical text; fenced code blocks keep indentation.
-- Links are **sanitised**: off-mesh `http`/`https` links in rendered content are removed or restricted so the preview cannot drive external navigation without mesh-style URLs.
-
-## HTML (`.html`)
-
-- **JavaScript** is not executed: `script` tags and event-handler attributes are stripped.
-- **External resources** are blocked where possible: `@import` and `url(...)` pointing at `http://`, `https://`, or protocol-relative URLs are removed from CSS. Embedded `<style>` blocks are kept; rules that target `html` or `body` are **rewritten** to apply to the viewer’s root container so your layout still applies.
-- **Links**: `href` values that are not mesh-style (`:` paths, 32-character hex prefixes, `/page/...`, `/file/...`, or `#` fragments) are removed. Images only keep `data:image/...` sources for inline images.
-- The viewer uses a **sans-serif** font for HTML and Markdown so pages do not inherit the monospace Micron chrome. You can override colours and typography with your own CSS.
-
-## Mesh Server API
-
-- `POST /api/v1/page-nodes/{node_id}/pages` with `name` and `content` saves a page; invalid extensions return **400** with a short message.
-- Listed pages only include files with allowed extensions in the `pages/` directory.
-
-## Archives
-
-Snapshots in **Archives** use the same rendering pipeline as the Nomad browser (Micron, Markdown, text, sanitised HTML) using the archived `page_path` to pick the format. Exports keep the original extension when it is `.mu`, `.md`, `.txt`, or `.html`.
-
-## See also
-
-- Architecture overview: `meshchatx.md` in this docs bundle.
-- Default Nomad entry path remains `/page/index.mu` unless you change the URL in the browser.

diff --git a/scripts/sync-meshchatx-docs.js b/scripts/sync-meshchatx-docs.js
index 9c22d991..bec1c437 100644
--- a/scripts/sync-meshchatx-docs.js
+++ b/scripts/sync-meshchatx-docs.js
@@ -1,5 +1,5 @@
/**
- * Copy docs/*.md into meshchatx/src/frontend/public/meshchatx-docs/ for in-app serving.
+ * Copy docs/ tree into meshchatx/src/frontend/public/meshchatx-docs/ for in-app serving.
* Source of truth: docs/ at repo root.
*/
@@ -10,6 +10,23 @@ const root = path.resolve(__dirname, "..");
const srcDir = path.join(root, "docs");
const destDir = path.join(root, "meshchatx", "src", "frontend", "public", "meshchatx-docs");
+const COPY_EXTENSIONS = new Set([".md", ".txt", ".json"]);
+
+function walkSync(dir, callback) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walkSync(fullPath, callback);
+ } else {
+ callback(fullPath);
+ }
+ }
+}
+
+function relativeFromDocs(filePath) {
+ return path.relative(srcDir, filePath);
+}
+
if (!fs.existsSync(srcDir)) {
console.error(`Missing docs directory: ${srcDir}`);
process.exit(1);
@@ -17,28 +34,44 @@ if (!fs.existsSync(srcDir)) {
fs.mkdirSync(destDir, { recursive: true });
-const sourceFiles = fs
- .readdirSync(srcDir)
- .filter((name) => name.endsWith(".md"))
- .sort();
+const sourceRelPaths = new Set();
-for (const file of sourceFiles) {
- const src = path.join(srcDir, file);
- const dest = path.join(destDir, file);
- const content = fs.readFileSync(src);
+walkSync(srcDir, (filePath) => {
+ const ext = path.extname(filePath);
+ const base = path.basename(filePath);
+ if (base !== "manifest.json" && !COPY_EXTENSIONS.has(ext)) {
+ return;
+ }
+ const rel = relativeFromDocs(filePath);
+ sourceRelPaths.add(rel);
+ const dest = path.join(destDir, rel);
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ const content = fs.readFileSync(filePath);
const prev = fs.existsSync(dest) ? fs.readFileSync(dest) : null;
if (!prev || !prev.equals(content)) {
fs.writeFileSync(dest, content);
- console.log(`Synced meshchatx-docs/${file}`);
+ console.log(`Synced meshchatx-docs/${rel.replace(/\\/g, "/")}`);
}
-}
+});
-for (const file of fs.readdirSync(destDir)) {
- if (!file.endsWith(".md")) {
- continue;
- }
- if (!sourceFiles.includes(file)) {
- fs.unlinkSync(path.join(destDir, file));
- console.log(`Removed stale meshchatx-docs/${file}`);
+function walkDest(dir, relBase = "") {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const rel = relBase ? path.join(relBase, entry.name) : entry.name;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walkDest(full, rel);
+ continue;
+ }
+ const ext = path.extname(entry.name);
+ const base = entry.name;
+ if (base !== "manifest.json" && !COPY_EXTENSIONS.has(ext)) {
+ continue;
+ }
+ if (!sourceRelPaths.has(rel)) {
+ fs.unlinkSync(full);
+ console.log(`Removed stale meshchatx-docs/${rel.replace(/\\/g, "/")}`);
+ }
}
}
+
+walkDest(destDir);

diff --git a/tests/backend/http_api_response_registry.py b/tests/backend/http_api_response_registry.py
index 8d2d5960..ac0e03e3 100644
--- a/tests/backend/http_api_response_registry.py
+++ b/tests/backend/http_api_response_registry.py
@@ -158,7 +158,7 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
"GET",
"/api/v1/meshchatx-docs/content",
MESHCHATX_DOCS_CONTENT_SCHEMA,
- query={"path": "meshchatx.md"},
+ query={"path": "en/getting-started.md"},
),
HttpJsonContract(
"GET",

diff --git a/tests/backend/test_archives_api_robustness.py b/tests/backend/test_archives_api_robustness.py
index 38f28c59..61f8ca7e 100644
--- a/tests/backend/test_archives_api_robustness.py
+++ b/tests/backend/test_archives_api_robustness.py
@@ -94,6 +94,31 @@ async def test_meshchatx_docs_content_requires_path(mock_app):
assert response.status == 400
+@pytest.mark.asyncio
+async def test_meshchatx_docs_content_rejects_invalid_path(mock_app):
+ handler = _handler(mock_app, "GET", "/api/v1/meshchatx-docs/content")
+ assert handler is not None
+ request = MagicMock()
+ request.query = {"path": "../secret.md"}
+ response = await handler(request)
+ assert response.status == 400
+ body = json.loads(response.body)
+ assert body["error"] == "Invalid path"
+
+
+@pytest.mark.asyncio
+async def test_meshchatx_docs_list_accepts_lang_query(mock_app):
+ handler = _handler(mock_app, "GET", "/api/v1/meshchatx-docs/list")
+ assert handler is not None
+ request = MagicMock()
+ request.query = {"lang": "de"}
+ response = await handler(request)
+ assert response.status == 200
+ body = json.loads(response.body)
+ assert "docs" in body
+ assert "sections" in body
+
+
@settings(
max_examples=40,
suppress_health_check=[HealthCheck.function_scoped_fixture],
@@ -107,7 +132,7 @@ async def test_meshchatx_docs_content_path_fuzz(mock_app, path):
request = MagicMock()
request.query = {"path": path}
response = await handler(request)
- assert response.status in (200, 404)
+ assert response.status in (200, 400, 404)
json.loads(response.body)

diff --git a/tests/backend/test_docs_manager.py b/tests/backend/test_docs_manager.py
index 0a418a34..ad318498 100644
--- a/tests/backend/test_docs_manager.py
+++ b/tests/backend/test_docs_manager.py
@@ -338,8 +338,14 @@ def test_populate_meshchatx_docs_generates_index_html(tmp_path):
public_dir.mkdir()
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
- (docs_dir / "README.md").write_text("# Hello\nWorld")
- (docs_dir / "FAQ.md").write_text("# FAQ\nQ&A")
+ en_dir = docs_dir / "en"
+ en_dir.mkdir()
+ (en_dir / "intro.md").write_text("# Hello\nWorld")
+ (docs_dir / "manifest.json").write_text(
+ '{"version":1,"default_language":"en","languages":[{"code":"en","name":"English"}],'
+ '"sections":[{"id":"main","order":1,"title":{"en":"Main"},"items":'
+ '[{"path":"en/intro.md","lang":"en","title":{"en":"Intro"}}]}]}',
+ )
config = MagicMock()
dm = DocsManager(config, str(public_dir), project_root=str(tmp_path))
@@ -349,8 +355,32 @@ def test_populate_meshchatx_docs_generates_index_html(tmp_path):
assert os.path.exists(index_path)
content = open(index_path, encoding="utf-8").read()
assert "MeshChatX Documentation" in content
- assert "README.html" in content
- assert "FAQ.html" in content
+ assert "en/intro.html" in content
+ assert "Intro" in content
+
+
+def test_get_meshchatx_docs_list_with_manifest(tmp_path):
+ public_dir = tmp_path / "public"
+ public_dir.mkdir()
+ mesh_docs = public_dir / "meshchatx-docs"
+ en_dir = mesh_docs / "en"
+ en_dir.mkdir(parents=True)
+ (en_dir / "intro.md").write_text("# Intro\n")
+ (mesh_docs / "manifest.json").write_text(
+ '{"version":1,"default_language":"en","languages":[{"code":"en","name":"English"}],'
+ '"sections":[{"id":"main","order":1,"title":{"en":"Overview"},"items":'
+ '[{"path":"en/intro.md","lang":"en","title":{"en":"Introduction"}}]}]}',
+ )
+
+ config = MagicMock()
+ dm = DocsManager(config, str(public_dir))
+ dm.meshchatx_docs_dir = str(mesh_docs)
+
+ listing = dm.get_meshchatx_docs_list("en")
+ assert listing["default_language"] == "en"
+ assert len(listing["docs"]) == 1
+ assert listing["sections"][0]["items"][0]["title"] == "Introduction"
+ assert dm.get_doc_content("en/intro.md")["type"] == "markdown"
def test_get_doc_content_rejects_directory_path(tmp_path):
@@ -366,3 +396,126 @@ def test_get_doc_content_rejects_directory_path(tmp_path):
assert dm.get_doc_content(".") is None
assert dm.get_doc_content("..") is None
assert dm.get_doc_content("") is None
+
+
+def test_is_safe_doc_path_rejects_unsafe_values():
+ assert DocsManager._is_safe_doc_path("en/guide.md") is True
+ assert DocsManager._is_safe_doc_path("../secret.md") is False
+ assert DocsManager._is_safe_doc_path("/etc/passwd") is False
+ assert DocsManager._is_safe_doc_path("en/../secret.md") is False
+ assert DocsManager._is_safe_doc_path("") is False
+ assert DocsManager._is_safe_doc_path("en/guide\0.md") is False
+
+
+def test_has_meshchatx_docs_finds_nested_files(tmp_path):
+ public_dir = tmp_path / "public"
+ public_dir.mkdir()
+ mesh_docs = public_dir / "meshchatx-docs" / "en"
+ mesh_docs.mkdir(parents=True)
+ (mesh_docs / "guide.md").write_text("# Guide\n")
+
+ config = MagicMock()
+ dm = DocsManager(config, str(public_dir))
+ dm.meshchatx_docs_dir = str(public_dir / "meshchatx-docs")
+
+ assert dm.has_meshchatx_docs() is True
+
+
+def test_get_doc_content_rejects_unsafe_paths(tmp_path):
+ public_dir = tmp_path / "public"
+ public_dir.mkdir()
+ config = MagicMock()
+ dm = DocsManager(config, str(public_dir))
+ os.makedirs(dm.meshchatx_docs_dir, exist_ok=True)
+
+ assert dm.get_doc_content("../outside.md") is None
+ assert dm.get_doc_content("/etc/passwd") is None
+
+
+def test_get_doc_content_nested_path(tmp_path):
+ public_dir = tmp_path / "public"
+ public_dir.mkdir()
+ en_dir = public_dir / "meshchatx-docs" / "en"
+ en_dir.mkdir(parents=True)
+ (en_dir / "guide.md").write_text("# Title\nBody")
+
+ config = MagicMock()
+ dm = DocsManager(config, str(public_dir))
+
+ content = dm.get_doc_content("en/guide.md")
+ assert content is not None
+ assert content["type"] == "markdown"
+ assert "Title" in content["html"]
+
+
+def test_invalid_manifest_returns_error_and_flat_fallback(tmp_path):
+ public_dir = tmp_path / "public"
+ public_dir.mkdir()
+ mesh_docs = public_dir / "meshchatx-docs" / "en"
+ mesh_docs.mkdir(parents=True)
+ (mesh_docs / "orphan.md").write_text("# Orphan\n")
+ (public_dir / "meshchatx-docs" / "manifest.json").write_text("{ not json")
+
+ config = MagicMock()
+ dm = DocsManager(config, str(public_dir))
+
+ listing = dm.get_meshchatx_docs_list("en")
+ assert listing["manifest_error"] == "Invalid manifest JSON"
+ assert len(listing["docs"]) == 1
+ assert listing["sections"][0]["id"] == "all"
+
+
+def test_manifest_skips_missing_files(tmp_path):
+ public_dir = tmp_path / "public"
+ public_dir.mkdir()
+ mesh_docs = public_dir / "meshchatx-docs"
+ en_dir = mesh_docs / "en"
+ en_dir.mkdir(parents=True)
+ (en_dir / "present.md").write_text("# Present\n")
+ (mesh_docs / "manifest.json").write_text(
+ '{"version":1,"default_language":"en","languages":[{"code":"en","name":"English"}],'
+ '"sections":[{"id":"main","order":1,"title":{"en":"Main"},"items":'
+ '[{"path":"en/missing.md","lang":"en","title":{"en":"Missing"}},'
+ '{"path":"en/present.md","lang":"en","title":{"en":"Present"}}]}]}',
+ )
+
+ config = MagicMock()
+ dm = DocsManager(config, str(public_dir))
+
+ listing = dm.get_meshchatx_docs_list("en")
+ assert len(listing["sections"]) == 1
+ assert len(listing["sections"][0]["items"]) == 1
+ assert listing["sections"][0]["items"][0]["path"] == "en/present.md"
+
+
+def test_search_finds_nested_meshchatx_docs(tmp_path):
+ public_dir = tmp_path / "public"
+ public_dir.mkdir()
+ en_dir = public_dir / "meshchatx-docs" / "en"
+ en_dir.mkdir(parents=True)
+ (en_dir / "guide.md").write_text("unique-nested-token-alpha\n")
+
+ config = MagicMock()
+ dm = DocsManager(config, str(public_dir))
+
+ results = dm.search("unique-nested-token-alpha", "en")
+ assert any(r["source"] == "MeshChatX" for r in results)
+ assert any("en/guide.md" in r["path"] for r in results)
+
+
+def test_get_doc_content_returns_none_on_read_error(tmp_path, monkeypatch):
+ public_dir = tmp_path / "public"
+ public_dir.mkdir()
+ en_dir = public_dir / "meshchatx-docs" / "en"
+ en_dir.mkdir(parents=True)
+ doc_path = en_dir / "guide.md"
+ doc_path.write_text("# Guide\n")
+
+ config = MagicMock()
+ dm = DocsManager(config, str(public_dir))
+
+ def fail_open(*_args, **_kwargs):
+ raise OSError("Permission denied")
+
+ monkeypatch.setattr("builtins.open", fail_open)
+ assert dm.get_doc_content("en/guide.md") is None

diff --git a/tests/backend/test_markdown_renderer.py b/tests/backend/test_markdown_renderer.py
index 6229e0c6..2d77b351 100644
--- a/tests/backend/test_markdown_renderer.py
+++ b/tests/backend/test_markdown_renderer.py
@@ -14,6 +14,25 @@ class TestMarkdownRenderer(unittest.TestCase):
self.assertIn("<strong>Bold</strong>", MarkdownRenderer.render("**Bold**"))
self.assertIn("<em>Italic</em>", MarkdownRenderer.render("*Italic*"))
+ def test_headings_receive_stable_ids(self):
+ rendered = MarkdownRenderer.render("## First section\n\n### Sub section")
+ self.assertIn('id="first-section"', rendered)
+ self.assertIn('id="sub-section"', rendered)
+
+ def test_tables_render_as_html(self):
+ md = (
+ "| Area | Route |\n"
+ "| ---- | ----- |\n"
+ "| Messages | /messages |\n"
+ "| Map | /map |\n"
+ )
+ rendered = MarkdownRenderer.render(md)
+ self.assertIn("<table", rendered)
+ self.assertIn("<th", rendered)
+ self.assertIn("Messages", rendered)
+ self.assertIn("/messages", rendered)
+ self.assertNotIn("| Messages |", rendered)
+
def test_links(self):
rendered = MarkdownRenderer.render("[Google](https://google.com)")
self.assertIn('href="https://google.com"', rendered)

diff --git a/tests/frontend/DocsPage.test.js b/tests/frontend/DocsPage.test.js
index 53baffa3..eee550f6 100644
--- a/tests/frontend/DocsPage.test.js
+++ b/tests/frontend/DocsPage.test.js
@@ -3,6 +3,33 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import DocsPage from "@/components/docs/DocsPage.vue";
import { nextTick, reactive } from "vue";
+vi.mock("@/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+const structuredList = {
+ docs: [{ name: "getting-started.md", path: "en/getting-started.md", type: "markdown" }],
+ sections: [
+ {
+ id: "overview",
+ title: "Overview",
+ items: [
+ {
+ path: "en/getting-started.md",
+ title: "Getting started",
+ lang: "en",
+ type: "markdown",
+ },
+ ],
+ },
+ ],
+ languages: [{ code: "en", name: "English" }],
+ default_language: "en",
+};
+
describe("DocsPage.vue", () => {
let axiosMock;
let i18nMock;
@@ -17,6 +44,7 @@ describe("DocsPage.vue", () => {
progress: 0,
last_error: null,
has_docs: false,
+ has_meshchatx_docs: true,
has_bundled_docs: false,
has_user_docs: false,
versions: [],
@@ -25,17 +53,32 @@ describe("DocsPage.vue", () => {
});
}
if (url.includes("/api/v1/meshchatx-docs/list")) {
- return Promise.resolve({ data: [] });
+ return Promise.resolve({ data: structuredList });
+ }
+ if (url.includes("/api/v1/meshchatx-docs/content")) {
+ return Promise.resolve({
+ data: {
+ html: '<h2 id="intro">Intro</h2><h3 id="details">Details</h3>',
+ content: "## Intro\n",
+ type: "markdown",
+ },
+ });
}
return Promise.resolve({ data: {} });
}),
post: vi.fn().mockResolvedValue({ data: {} }),
+ patch: vi.fn().mockResolvedValue({ data: {} }),
+ delete: vi.fn().mockResolvedValue({ data: {} }),
};
window.api = axiosMock;
i18nMock = reactive({ locale: "en" });
+ vi.spyOn(window, "confirm").mockReturnValue(true);
+ vi.spyOn(window, "prompt").mockReturnValue("v-test");
+ vi.spyOn(window, "alert").mockImplementation(() => {});
});
afterEach(() => {
+ vi.restoreAllMocks();
if (wrapper) {
wrapper.unmount();
}
@@ -49,11 +92,26 @@ describe("DocsPage.vue", () => {
"click-outside": vi.fn(),
},
mocks: {
- $t: (key) => key,
+ $t: (key, params) => {
+ if (params && params.count !== undefined) {
+ return `${key}:${params.count}`;
+ }
+ if (params && params.percent !== undefined) {
+ return `${key}:${params.percent}`;
+ }
+ if (params && params.message !== undefined) {
+ return `${key}:${params.message}`;
+ }
+ if (params && params.version !== undefined) {
+ return `${key}:${params.version}`;
+ }
+ return key;
+ },
$i18n: i18nMock,
},
stubs: {
MaterialDesignIcon: true,
+ ToolsPageHeader: true,
},
},
});
@@ -61,11 +119,32 @@ describe("DocsPage.vue", () => {
};
it("renders upload prompt when no docs are present", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url.includes("/api/v1/docs/status")) {
+ return Promise.resolve({
+ data: {
+ status: "idle",
+ progress: 0,
+ last_error: null,
+ has_docs: false,
+ has_meshchatx_docs: false,
+ has_bundled_docs: false,
+ has_user_docs: false,
+ versions: [],
+ current_version: null,
+ },
+ });
+ }
+ if (url.includes("/api/v1/meshchatx-docs/list")) return Promise.resolve({ data: [] });
+ return Promise.resolve({ data: {} });
+ });
+
const wrapper = mountDocsPage();
+ wrapper.vm.activeTab = "reticulum";
await nextTick();
await nextTick();
- expect(wrapper.text()).toContain("Reticulum Manual");
+ expect(wrapper.text()).toContain("docs.reticulum_manual");
expect(wrapper.text()).toContain("docs.empty_state_hint");
expect(wrapper.text()).toContain("docs.btn_upload");
});
@@ -238,4 +317,228 @@ describe("DocsPage.vue", () => {
expect(wrapper.text()).toContain("docs.error");
expect(wrapper.text()).toContain(longError.substring(0, 100));
});
+
+ it("loads structured sections and auto-selects the first guide", async () => {
+ const wrapper = mountDocsPage();
+ await nextTick();
+ await nextTick();
+ await nextTick();
+
+ expect(wrapper.text()).toContain("Overview");
+ expect(wrapper.text()).toContain("Getting started");
+ expect(wrapper.vm.selectedDocPath).toBe("en/getting-started.md");
+ expect(wrapper.vm.docToc).toEqual([
+ { id: "intro", text: "Intro", level: 2 },
+ { id: "details", text: "Details", level: 3 },
+ ]);
+ });
+
+ it("shows list error when meshchatx docs list request fails", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url.includes("/api/v1/docs/status")) {
+ return Promise.resolve({
+ data: {
+ status: "idle",
+ progress: 0,
+ last_error: null,
+ has_docs: false,
+ has_meshchatx_docs: true,
+ has_bundled_docs: false,
+ has_user_docs: false,
+ versions: [],
+ current_version: null,
+ },
+ });
+ }
+ if (url.includes("/api/v1/meshchatx-docs/list")) {
+ return Promise.reject({
+ response: { data: { error: "Server exploded" } },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountDocsPage();
+ await nextTick();
+ await nextTick();
+
+ expect(wrapper.vm.meshchatxListError).toBe("Server exploded");
+ expect(wrapper.text()).toContain("Server exploded");
+ });
+
+ it("shows doc load error when content request fails", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url.includes("/api/v1/docs/status")) {
+ return Promise.resolve({
+ data: {
+ status: "idle",
+ progress: 0,
+ last_error: null,
+ has_docs: false,
+ has_meshchatx_docs: true,
+ has_bundled_docs: false,
+ has_user_docs: false,
+ versions: [],
+ current_version: null,
+ },
+ });
+ }
+ if (url.includes("/api/v1/meshchatx-docs/list")) {
+ return Promise.resolve({ data: structuredList });
+ }
+ if (url.includes("/api/v1/meshchatx-docs/content")) {
+ return Promise.reject({
+ response: { data: { error: "Document not found" } },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountDocsPage();
+ await nextTick();
+ await nextTick();
+ await nextTick();
+
+ expect(wrapper.vm.docLoadError).toBe("Document not found");
+ expect(wrapper.text()).toContain("docs.load_doc_failed");
+ });
+
+ it("shows manifest warning when list includes manifest_error", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url.includes("/api/v1/docs/status")) {
+ return Promise.resolve({
+ data: {
+ status: "idle",
+ progress: 0,
+ last_error: null,
+ has_docs: false,
+ has_meshchatx_docs: true,
+ has_bundled_docs: false,
+ has_user_docs: false,
+ versions: [],
+ current_version: null,
+ },
+ });
+ }
+ if (url.includes("/api/v1/meshchatx-docs/list")) {
+ return Promise.resolve({
+ data: { ...structuredList, manifest_error: "Invalid manifest JSON" },
+ });
+ }
+ if (url.includes("/api/v1/meshchatx-docs/content")) {
+ return Promise.resolve({
+ data: { html: "<p>ok</p>", content: "ok", type: "markdown" },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountDocsPage();
+ await nextTick();
+ await nextTick();
+
+ expect(wrapper.vm.manifestWarning).toBe("docs.manifest_warning");
+ expect(wrapper.text()).toContain("docs.manifest_warning");
+ });
+
+ it("extractDocToc returns empty array for invalid html", () => {
+ const wrapper = mountDocsPage();
+ expect(wrapper.vm.extractDocToc("")).toEqual([]);
+ expect(wrapper.vm.extractDocToc("<p>no headings</p>")).toEqual([]);
+ });
+
+ it("navigateTo selects nested meshchatx docs from search results", async () => {
+ const wrapper = mountDocsPage();
+ await nextTick();
+ await nextTick();
+
+ const selectSpy = vi.spyOn(wrapper.vm, "selectDoc");
+ wrapper.vm.navigateTo("/meshchatx-docs/en/getting-started.md");
+ await nextTick();
+
+ expect(wrapper.vm.activeTab).toBe("meshchatx");
+ expect(selectSpy).toHaveBeenCalledWith("en/getting-started.md");
+ expect(wrapper.vm.searchQuery).toBe("");
+ });
+
+ it("shows search error state when search request fails", async () => {
+ const wrapper = mountDocsPage();
+ await nextTick();
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url.includes("/api/v1/docs/search")) {
+ return Promise.reject(new Error("network down"));
+ }
+ if (url.includes("/api/v1/docs/status")) {
+ return Promise.resolve({
+ data: {
+ status: "idle",
+ progress: 0,
+ last_error: null,
+ has_docs: true,
+ has_meshchatx_docs: true,
+ has_bundled_docs: true,
+ has_user_docs: false,
+ versions: [],
+ current_version: null,
+ },
+ });
+ }
+ if (url.includes("/api/v1/meshchatx-docs/list")) {
+ return Promise.resolve({ data: structuredList });
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ wrapper.vm.searchQuery = "reticulum";
+ await wrapper.vm.performSearch();
+ await nextTick();
+
+ expect(wrapper.vm.searchError).toBe("docs.search_failed");
+ expect(wrapper.text()).toContain("docs.search_failed");
+ });
+
+ it("scrollToHeading targets the rendered article element", async () => {
+ const wrapper = mountDocsPage();
+ await nextTick();
+ await nextTick();
+ await nextTick();
+
+ const intro = wrapper.vm.$refs.docsProse.querySelector("#intro");
+ intro.scrollIntoView = vi.fn();
+
+ wrapper.vm.scrollToHeading("intro");
+
+ expect(intro.scrollIntoView).toHaveBeenCalled();
+ });
+
+ it("onReticulumFrameLoad reveals the iframe", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url.includes("/api/v1/docs/status")) {
+ return Promise.resolve({
+ data: {
+ status: "idle",
+ progress: 100,
+ last_error: null,
+ has_docs: true,
+ has_meshchatx_docs: false,
+ has_bundled_docs: true,
+ has_user_docs: false,
+ versions: [],
+ current_version: "bundled",
+ },
+ });
+ }
+ if (url.includes("/api/v1/meshchatx-docs/list")) return Promise.resolve({ data: [] });
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mountDocsPage();
+ await nextTick();
+ await nextTick();
+
+ wrapper.vm.$refs.docsFrame = { style: { opacity: "0" } };
+ wrapper.vm.onReticulumFrameLoad();
+ expect(wrapper.vm.$refs.docsFrame.style.opacity).toBe("1");
+ });
});

diff --git a/vite.config.js b/vite.config.js
index db0d542a..8c7d3436 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -112,6 +112,8 @@ export default defineConfig({
"/api": { target: e2eBackendOrigin, changeOrigin: true, ...backendProxyTls },
"/ws": { target: e2eBackendWs, ws: true, ...backendProxyTls },
"/ws/telephone/audio": { target: e2eBackendWs, ws: true, ...backendProxyTls },
+ "/reticulum-docs": { target: e2eBackendOrigin, changeOrigin: true, ...backendProxyTls },
+ "/meshchatx-docs": { target: e2eBackendOrigin, changeOrigin: true, ...backendProxyTls },
},
},


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────